mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
feat(studio): add keyframe timeline state
This commit is contained in:
@@ -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 } from "./gsapShared";
|
||||
import { computeElementPercentage, idSelector } from "./gsapShared";
|
||||
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
||||
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
||||
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
|
||||
@@ -117,7 +117,7 @@ export async function materializeIfDynamic(
|
||||
const allScanned = scanAllRuntimeKeyframes(iframe);
|
||||
if (allScanned.size === 0) return;
|
||||
const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({
|
||||
selector: `#${id}`,
|
||||
selector: idSelector(id),
|
||||
keyframes: data.keyframes,
|
||||
easeEach: data.easeEach,
|
||||
}));
|
||||
|
||||
@@ -28,7 +28,7 @@ const animWithKeyframes = (id: string): GsapAnimation => ({
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
usePlayerStore.setState({ keyframeCache: new Map(), elements: [] });
|
||||
usePlayerStore.setState({ keyframeCache: new Map(), gsapAnimations: new Map(), elements: [] });
|
||||
});
|
||||
|
||||
describe("clearKeyframeCacheForElement", () => {
|
||||
@@ -98,6 +98,41 @@ describe("clearKeyframeCacheForFile", () => {
|
||||
});
|
||||
|
||||
describe("updateKeyframeCacheFromParsed", () => {
|
||||
it("serializes a multi-keyframe tween with a stable shape and animation identity", () => {
|
||||
const animation: GsapAnimation = {
|
||||
...animWithKeyframes("hero"),
|
||||
duration: 2,
|
||||
resolvedStart: 3,
|
||||
keyframes: {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0 } },
|
||||
{ percentage: 50, properties: { x: 100 }, ease: "power1.inOut" },
|
||||
{ percentage: 100, properties: { x: 200 } },
|
||||
],
|
||||
easeEach: "power1.inOut",
|
||||
},
|
||||
};
|
||||
usePlayerStore.setState({
|
||||
elements: [
|
||||
{
|
||||
id: "hero-clip",
|
||||
domId: "hero",
|
||||
tag: "div",
|
||||
start: 2,
|
||||
duration: 4,
|
||||
track: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
updateKeyframeCacheFromParsed([animation], "scene.html", "hero", {});
|
||||
|
||||
expect(JSON.stringify(cache().get("scene.html#hero"))).toBe(
|
||||
'{"format":"percentage","keyframes":[{"percentage":25,"properties":{"x":0},"tweenPercentage":0,"propertyGroup":"position","animationId":"hero"},{"percentage":50,"properties":{"x":100},"ease":"power1.inOut","tweenPercentage":50,"propertyGroup":"position","animationId":"hero"},{"percentage":75,"properties":{"x":200},"tweenPercentage":100,"propertyGroup":"position","animationId":"hero"}],"easeEach":"power1.inOut"}',
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the bare key when the selected element no longer has keyframes", () => {
|
||||
// Element previously had keyframes, so a bare entry exists (writes set both).
|
||||
seed("index.html#box");
|
||||
@@ -118,4 +153,63 @@ describe("updateKeyframeCacheFromParsed", () => {
|
||||
expect(cache().has("index.html#hero")).toBe(true);
|
||||
expect(cache().has("hero")).toBe(true);
|
||||
});
|
||||
|
||||
it("caches flat tweens as clip-relative start and end keyframes", () => {
|
||||
const animation: GsapAnimation = {
|
||||
id: "flat-box",
|
||||
targetSelector: "#box",
|
||||
method: "to",
|
||||
position: 1,
|
||||
properties: { x: 420 },
|
||||
duration: 2,
|
||||
resolvedStart: 1,
|
||||
ease: "power2.out",
|
||||
propertyGroup: "position",
|
||||
};
|
||||
usePlayerStore.setState({
|
||||
elements: [{ id: "box-clip", domId: "box", tag: "div", start: 1, duration: 2, track: 0 }],
|
||||
});
|
||||
|
||||
updateKeyframeCacheFromParsed([animation], "scene.html", "box", {});
|
||||
|
||||
expect(cache().get("scene.html#box")).toEqual({
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{
|
||||
percentage: 0,
|
||||
properties: { x: 0 },
|
||||
tweenPercentage: 0,
|
||||
propertyGroup: "position",
|
||||
animationId: "flat-box",
|
||||
},
|
||||
{
|
||||
percentage: 100,
|
||||
properties: { x: 420 },
|
||||
ease: "power2.out",
|
||||
tweenPercentage: 100,
|
||||
propertyGroup: "position",
|
||||
animationId: "flat-box",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(usePlayerStore.getState().gsapAnimations.get("scene.html#box")).toEqual([animation]);
|
||||
});
|
||||
|
||||
it("does not cache a flat tween without animatable numeric properties", () => {
|
||||
const animation: GsapAnimation = {
|
||||
id: "flat-box",
|
||||
targetSelector: "#box",
|
||||
method: "to",
|
||||
position: 0,
|
||||
properties: { backgroundColor: "#fff" },
|
||||
duration: 1,
|
||||
propertyGroup: "visual",
|
||||
};
|
||||
|
||||
updateKeyframeCacheFromParsed([animation], "scene.html", "box", {});
|
||||
|
||||
expect(cache().has("scene.html#box")).toBe(false);
|
||||
expect(cache().has("box")).toBe(false);
|
||||
expect(usePlayerStore.getState().gsapAnimations.has("scene.html#box")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
|
||||
import { toAbsoluteTime } from "./gsapShared";
|
||||
import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
|
||||
|
||||
export function updateKeyframeCacheFromParsed(
|
||||
animations: GsapAnimation[],
|
||||
@@ -15,10 +16,16 @@ export function updateKeyframeCacheFromParsed(
|
||||
const { setKeyframeCache, elements } = usePlayerStore.getState();
|
||||
const idsWithKeyframes = new Set<string>();
|
||||
const merged = new Map<string, KeyframeCacheEntry>();
|
||||
const sourceAnimations = new Map<string, GsapAnimation[]>();
|
||||
for (const anim of animations) {
|
||||
const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1];
|
||||
if (!id || !anim.keyframes) continue;
|
||||
const kfSource =
|
||||
anim.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(anim)?.keyframes ?? [];
|
||||
if (!id || kfSource.length === 0) continue;
|
||||
idsWithKeyframes.add(id);
|
||||
if (anim.propertyGroup) {
|
||||
sourceAnimations.set(id, [...(sourceAnimations.get(id) ?? []), anim]);
|
||||
}
|
||||
|
||||
// Convert tween-relative percentages to clip-relative so diamonds
|
||||
// render at the correct position within the timeline clip.
|
||||
@@ -29,7 +36,7 @@ export function updateKeyframeCacheFromParsed(
|
||||
);
|
||||
const elStart = timelineEl?.start ?? 0;
|
||||
const elDuration = timelineEl?.duration ?? 1;
|
||||
const clipKeyframes = anim.keyframes.keyframes.map((kf) => {
|
||||
const clipKeyframes = kfSource.map((kf) => {
|
||||
const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
|
||||
const clipPct =
|
||||
elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage;
|
||||
@@ -38,6 +45,7 @@ export function updateKeyframeCacheFromParsed(
|
||||
percentage: clipPct,
|
||||
tweenPercentage: kf.percentage,
|
||||
propertyGroup: anim.propertyGroup,
|
||||
animationId: anim.id,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -48,6 +56,17 @@ export function updateKeyframeCacheFromParsed(
|
||||
const prev = byPct.get(kf.percentage);
|
||||
if (prev) {
|
||||
prev.properties = { ...prev.properties, ...kf.properties };
|
||||
// Mirror deduplicateKeyframes: a same-% collision across different
|
||||
// source animations is an ambiguous merged segment (the button can
|
||||
// only target one arbitrary animation). Flag it so the collapsed row
|
||||
// suppresses the inline ease button there.
|
||||
if (
|
||||
prev.animationId !== undefined &&
|
||||
kf.animationId !== undefined &&
|
||||
prev.animationId !== kf.animationId
|
||||
) {
|
||||
prev.easeAmbiguous = true;
|
||||
}
|
||||
if (kf.ease) prev.ease = kf.ease;
|
||||
} else {
|
||||
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
|
||||
@@ -55,13 +74,18 @@ export function updateKeyframeCacheFromParsed(
|
||||
}
|
||||
existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage);
|
||||
} else {
|
||||
merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes });
|
||||
merged.set(id, {
|
||||
...anim.keyframes,
|
||||
format: anim.keyframes?.format ?? "percentage",
|
||||
keyframes: clipKeyframes,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const [id, entry] of merged) {
|
||||
setKeyframeCache(`${targetPath}#${id}`, entry);
|
||||
setKeyframeCache(id, entry);
|
||||
if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry);
|
||||
writeGsapAnimationsForElement(targetPath, id, sourceAnimations.get(id));
|
||||
}
|
||||
const targetId =
|
||||
(mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ??
|
||||
@@ -84,13 +108,12 @@ export function updateKeyframeCacheFromParsed(
|
||||
* a new cache map and re-render every subscriber.
|
||||
*/
|
||||
export function clearKeyframeCacheForElement(sourceFile: string, elementId: string): void {
|
||||
const { keyframeCache, setKeyframeCache } = usePlayerStore.getState();
|
||||
const keys =
|
||||
sourceFile === "index.html"
|
||||
? [`index.html#${elementId}`, elementId]
|
||||
: [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
|
||||
const { keyframeCache, setKeyframeCache, gsapAnimations, setGsapAnimations } =
|
||||
usePlayerStore.getState();
|
||||
const keys = elementCacheKeys(sourceFile, elementId);
|
||||
for (const key of keys) {
|
||||
if (keyframeCache.has(key)) setKeyframeCache(key, undefined);
|
||||
if (gsapAnimations.has(key)) setGsapAnimations(key, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,11 +125,11 @@ export function clearKeyframeCacheForElement(sourceFile: string, elementId: stri
|
||||
* absent from the re-scan) leaves no stale bare entry behind.
|
||||
*/
|
||||
export function clearKeyframeCacheForFile(sourceFile: string): void {
|
||||
const { keyframeCache } = usePlayerStore.getState();
|
||||
const { keyframeCache, gsapAnimations } = usePlayerStore.getState();
|
||||
const sfPrefix = `${sourceFile}#`;
|
||||
const fallbackPrefix = "index.html#";
|
||||
const ids = new Set<string>();
|
||||
for (const key of keyframeCache.keys()) {
|
||||
for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) {
|
||||
const matchesFile =
|
||||
key.startsWith(sfPrefix) || (sourceFile !== "index.html" && key.startsWith(fallbackPrefix));
|
||||
if (!matchesFile) continue;
|
||||
@@ -118,6 +141,23 @@ export function clearKeyframeCacheForFile(sourceFile: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function elementCacheKeys(sourceFile: string, elementId: string): string[] {
|
||||
return sourceFile === "index.html"
|
||||
? [`index.html#${elementId}`, elementId]
|
||||
: [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId];
|
||||
}
|
||||
|
||||
export function writeGsapAnimationsForElement(
|
||||
sourceFile: string,
|
||||
elementId: string,
|
||||
animations: GsapAnimation[] | undefined,
|
||||
): void {
|
||||
const { setGsapAnimations } = usePlayerStore.getState();
|
||||
for (const key of elementCacheKeys(sourceFile, elementId)) {
|
||||
setGsapAnimations(key, animations);
|
||||
}
|
||||
}
|
||||
|
||||
function buildCacheKey(sourceFile: string, elementId: string): string {
|
||||
return `${sourceFile}#${elementId}`;
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@ 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";
|
||||
|
||||
export function ensureElementAddressable(selection: DomEditSelection): {
|
||||
selector: string;
|
||||
autoId?: string;
|
||||
} {
|
||||
if (selection.id) return { selector: `#${selection.id}` };
|
||||
if (selection.id) return { selector: idSelector(selection.id) };
|
||||
if (selection.selector) return { selector: selection.selector };
|
||||
|
||||
const el = selection.element;
|
||||
@@ -20,7 +21,7 @@ export function ensureElementAddressable(selection: DomEditSelection): {
|
||||
id = `${tag}-${n}`;
|
||||
}
|
||||
el.setAttribute("id", id);
|
||||
return { selector: `#${id}`, autoId: id };
|
||||
return { selector: idSelector(id), autoId: id };
|
||||
}
|
||||
|
||||
export class GsapMutationHttpError extends Error {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { isInstantHold, parsePercentageKeyframes } from "./gsapShared";
|
||||
import { idSelector, isInstantHold, parsePercentageKeyframes } from "./gsapShared";
|
||||
|
||||
describe("isInstantHold", () => {
|
||||
const animation = (method: GsapAnimation["method"], duration?: number) =>
|
||||
@@ -74,3 +74,32 @@ describe("parsePercentageKeyframes", () => {
|
||||
expect(parsePercentageKeyframes({})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("idSelector", () => {
|
||||
it("uses #id for valid CSS identifiers", () => {
|
||||
expect(idSelector("hero-word")).toBe("#hero-word");
|
||||
expect(idSelector("el_1")).toBe("#el_1");
|
||||
});
|
||||
|
||||
it("uses an attribute selector for ids that #id can't address (digit-leading, dots, spaces)", () => {
|
||||
// #01-... / #a.b / #a b throw a SyntaxError in querySelector / GSAP, crashing
|
||||
// the preview when such a target is committed (e.g. dragging the element).
|
||||
expect(idSelector("01-hook-hero-word")).toBe('[id="01-hook-hero-word"]');
|
||||
expect(idSelector("my.class")).toBe('[id="my.class"]');
|
||||
expect(idSelector("1box")).toBe('[id="1box"]');
|
||||
});
|
||||
|
||||
it("escapes quotes and backslashes in the attribute selector value", () => {
|
||||
expect(idSelector('1"x')).toBe('[id="1\\"x"]');
|
||||
});
|
||||
|
||||
it("only ever emits #id for ids that can't break querySelector", () => {
|
||||
// Every id resolves to either a plain #id (only when safe) or an attribute
|
||||
// selector — never a #id that would throw a SyntaxError.
|
||||
for (const id of ["hero-word", "01-hook", "a.b", "a b", "1", "--x", '1"q']) {
|
||||
const sel = idSelector(id);
|
||||
if (sel.startsWith("#")) expect(sel).toBe(`#${id}`);
|
||||
else expect(sel.startsWith('[id="')).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,8 +53,35 @@ export function isInstantHold(animation: GsapAnimation): boolean {
|
||||
* Returns `#id` if the selection has an id, otherwise the raw selector,
|
||||
* or null if neither exists.
|
||||
*/
|
||||
/**
|
||||
* A CSS-valid selector for an element id. `#id` for a valid CSS identifier,
|
||||
* otherwise an `[id="..."]` attribute selector. IDs that start with a digit
|
||||
* (e.g. "01-hook-hero-word") make `#id` an invalid selector, so
|
||||
* `document.querySelector("#01-...")` / GSAP's `querySelectorAll` throw a
|
||||
* SyntaxError — which surfaces as a masked cross-origin "Script error." and
|
||||
* crashes the preview the moment such a target is committed (e.g. dragging).
|
||||
*/
|
||||
// Conservative: matches only ids that are unquestionably safe as a `#id`
|
||||
// selector — ASCII identifier, starts with a letter/underscore (or a single
|
||||
// leading hyphen), no dots/colons/spaces/digits-first. Anything it rejects
|
||||
// (digit-leading like "01-hook-...", dots, spaces, non-ASCII, …) falls through
|
||||
// to the attribute selector below, which is always valid. It can only ever err
|
||||
// toward the safe form, never toward a `#id` that throws — and, unlike
|
||||
// `CSS.escape`, it needs no browser global (this runs in node tests too).
|
||||
const SAFE_HASH_ID = /^-?[A-Za-z_][\w-]*$/;
|
||||
|
||||
export function idSelector(id: string): string {
|
||||
// A `#id` selector is only valid for a CSS identifier. IDs that start with a
|
||||
// digit (e.g. "01-hook-hero-word") make `document.querySelector("#01-...")` and
|
||||
// GSAP's `querySelectorAll` throw a SyntaxError — surfacing as a masked
|
||||
// cross-origin "Script error." that crashes the preview the moment such a
|
||||
// target is committed (e.g. dragging the element). Address those via an
|
||||
// attribute selector instead (quotes/backslashes escaped for the string).
|
||||
return SAFE_HASH_ID.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`;
|
||||
}
|
||||
|
||||
export function selectorFromSelection(selection: DomEditSelection): string | null {
|
||||
if (selection.id) return `#${selection.id}`;
|
||||
if (selection.id) return idSelector(selection.id);
|
||||
if (selection.selector) return selection.selector;
|
||||
return null;
|
||||
}
|
||||
@@ -118,6 +145,18 @@ export interface ParsedPercentageKeyframes {
|
||||
easeEach?: string;
|
||||
}
|
||||
|
||||
function collectAnimatableKeyframeProperties(
|
||||
entry: Record<string, unknown>,
|
||||
): Record<string, number | string> {
|
||||
const properties: Record<string, number | string> = {};
|
||||
for (const [property, value] of Object.entries(entry)) {
|
||||
if (property === "ease") continue;
|
||||
if (typeof value === "number") properties[property] = Math.round(value * 1000) / 1000;
|
||||
else if (typeof value === "string") properties[property] = value;
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a GSAP percentage-keyframe object (`{ "0%": { x: 10 }, "100%": { x: 200 } }`)
|
||||
* into a sorted array of `{ percentage, properties }` entries.
|
||||
@@ -146,12 +185,7 @@ export function parsePercentageKeyframes(
|
||||
steps.forEach((entry, i) => {
|
||||
if (!entry || typeof entry !== "object") return;
|
||||
const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0;
|
||||
const properties: Record<string, number | string> = {};
|
||||
for (const [pk, pv] of Object.entries(entry as Record<string, unknown>)) {
|
||||
if (pk === "ease") continue;
|
||||
if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
|
||||
else if (typeof pv === "string") properties[pk] = pv;
|
||||
}
|
||||
const properties = collectAnimatableKeyframeProperties(entry as Record<string, unknown>);
|
||||
if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties });
|
||||
});
|
||||
return keyframes.length > 0 ? { keyframes } : null;
|
||||
@@ -165,12 +199,7 @@ export function parsePercentageKeyframes(
|
||||
const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/);
|
||||
if (!pctMatch || !val || typeof val !== "object") continue;
|
||||
const percentage = parseFloat(pctMatch[1]);
|
||||
const properties: Record<string, number | string> = {};
|
||||
for (const [pk, pv] of Object.entries(val as Record<string, unknown>)) {
|
||||
if (pk === "ease") continue;
|
||||
if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000;
|
||||
else if (typeof pv === "string") properties[pk] = pv;
|
||||
}
|
||||
const properties = collectAnimatableKeyframeProperties(val as Record<string, unknown>);
|
||||
if (Object.keys(properties).length > 0) {
|
||||
keyframes.push({ percentage, properties });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
|
||||
import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
|
||||
|
||||
function anim(overrides: Partial<GsapAnimation>): GsapAnimation {
|
||||
return {
|
||||
@@ -53,3 +53,32 @@ describe("synthesizeFlatTweenKeyframes", () => {
|
||||
expect(out).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deduplicateKeyframes ease ambiguity", () => {
|
||||
it("flags a same-% collision from different animations (different eases)", () => {
|
||||
const merged = deduplicateKeyframes([
|
||||
{ percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
|
||||
{ percentage: 45, properties: { opacity: 1 }, ease: "power2.out", animationId: "#a-visual" },
|
||||
]);
|
||||
const kf = merged.find((k) => k.percentage === 45);
|
||||
expect(kf?.easeAmbiguous).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a cross-animation collision even when the raw eases match", () => {
|
||||
// The button can still only target one arbitrary animation, and each may
|
||||
// inherit a different easeEach/animation ease that raw comparison misses.
|
||||
const merged = deduplicateKeyframes([
|
||||
{ percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
|
||||
{ percentage: 45, properties: { opacity: 1 }, ease: "power2.in", animationId: "#a-visual" },
|
||||
]);
|
||||
expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBe(true);
|
||||
});
|
||||
|
||||
it("does not flag a same-% collision within a single animation", () => {
|
||||
const merged = deduplicateKeyframes([
|
||||
{ percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" },
|
||||
{ percentage: 45, properties: { y: 20 }, ease: "power2.out", animationId: "#a-position" },
|
||||
]);
|
||||
expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,14 +5,26 @@ import type {
|
||||
} from "@hyperframes/core/gsap-parser";
|
||||
import { PROPERTY_DEFAULTS } from "./gsapShared";
|
||||
|
||||
export function deduplicateKeyframes(
|
||||
keyframes: GsapPercentageKeyframe[],
|
||||
): GsapPercentageKeyframe[] {
|
||||
const byPct = new Map<number, GsapPercentageKeyframe>();
|
||||
export function deduplicateKeyframes<
|
||||
T extends GsapPercentageKeyframe & { animationId?: string; easeAmbiguous?: boolean },
|
||||
>(keyframes: T[]): T[] {
|
||||
const byPct = new Map<number, T>();
|
||||
for (const kf of keyframes) {
|
||||
const existing = byPct.get(kf.percentage);
|
||||
if (existing) {
|
||||
existing.properties = { ...existing.properties, ...kf.properties };
|
||||
// Two DIFFERENT source animations with a keyframe at the same clip %: a
|
||||
// single inline ease button can only target one of them, and which one is
|
||||
// arbitrary (each may also inherit a different easeEach/animation ease, so
|
||||
// comparing raw keyframe eases isn't enough). Flag it so the collapsed row
|
||||
// hides the button there and the user edits per-lane instead.
|
||||
if (
|
||||
existing.animationId !== undefined &&
|
||||
kf.animationId !== undefined &&
|
||||
existing.animationId !== kf.animationId
|
||||
) {
|
||||
existing.easeAmbiguous = true;
|
||||
}
|
||||
if (kf.ease) existing.ease = kf.ease;
|
||||
} else {
|
||||
byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } });
|
||||
@@ -41,29 +53,40 @@ export function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframes
|
||||
const fromProps = anim.fromProperties;
|
||||
if (!toProps || Object.keys(toProps).length === 0) return null;
|
||||
|
||||
const startProps: Record<string, number | string> = {};
|
||||
const endProps: Record<string, number | string> = {};
|
||||
const rawStart: Record<string, number | string> = {};
|
||||
const rawEnd: Record<string, number | string> = {};
|
||||
|
||||
if (anim.method === "from") {
|
||||
for (const [k, v] of Object.entries(toProps)) {
|
||||
startProps[k] = v;
|
||||
endProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
||||
rawStart[k] = v;
|
||||
rawEnd[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
||||
}
|
||||
} else if (anim.method === "fromTo" && fromProps) {
|
||||
Object.assign(startProps, fromProps);
|
||||
Object.assign(endProps, toProps);
|
||||
Object.assign(rawStart, fromProps);
|
||||
Object.assign(rawEnd, toProps);
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(toProps)) {
|
||||
startProps[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
||||
endProps[k] = v;
|
||||
rawStart[k] = PROPERTY_DEFAULTS[k] ?? 0;
|
||||
rawEnd[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
// Only numeric props are keyframe-interpolatable — a flat tween of a
|
||||
// non-numeric prop (e.g. backgroundColor: "#fff") can't be a 2-keyframe lane.
|
||||
const numericKeys = Object.keys(rawEnd).filter(
|
||||
(k) => typeof rawStart[k] === "number" && typeof rawEnd[k] === "number",
|
||||
);
|
||||
if (numericKeys.length === 0) return null;
|
||||
const startProps = Object.fromEntries(numericKeys.map((k) => [k, rawStart[k]]));
|
||||
const endProps = Object.fromEntries(numericKeys.map((k) => [k, rawEnd[k]]));
|
||||
|
||||
return {
|
||||
format: "percentage",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: startProps },
|
||||
{ percentage: 100, properties: endProps },
|
||||
// Segment ease lives on the destination keyframe (Figma/AE model) so the
|
||||
// lane + cache surface it; also kept data-level for useGsapTweenCache.
|
||||
{ percentage: 100, properties: endProps, ...(anim.ease ? { ease: anim.ease } : {}) },
|
||||
],
|
||||
...(anim.ease ? { ease: anim.ease } : {}),
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type DomEditSelection,
|
||||
} from "../components/editor/domEditing";
|
||||
import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits";
|
||||
import { useStudioTestHooks } from "./useStudioTestHooks";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
@@ -506,6 +507,9 @@ export function useDomSelection({
|
||||
applyDomSelection(null, { revealPanel: false });
|
||||
}, [applyDomSelection, captionEditMode]);
|
||||
|
||||
// Dev-only headless-QA shortcut (window.__studioTest.selectByDomId). No-op in prod.
|
||||
useStudioTestHooks({ previewIframeRef, buildDomSelectionFromTarget, applyDomSelection });
|
||||
|
||||
const applyMarqueeSelection = useCallback(
|
||||
// fallow-ignore-next-line complexity
|
||||
(selections: DomEditSelection[], additive: boolean) => {
|
||||
|
||||
@@ -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 } from "./gsapShared";
|
||||
import { isInstantHold, idSelector } from "./gsapShared";
|
||||
|
||||
type RecordedKeyframe = {
|
||||
percentage: number;
|
||||
@@ -168,7 +168,7 @@ export function useGestureCommit({
|
||||
if (!sortedPcts.includes(0)) sortedPcts.unshift(0);
|
||||
}
|
||||
|
||||
const selector = sel.id ? `#${sel.id}` : sel.selector;
|
||||
const selector = sel.id ? idSelector(sel.id) : sel.selector;
|
||||
if (!selector) {
|
||||
showToast("Cannot save — element has no selector", "error");
|
||||
return;
|
||||
|
||||
@@ -19,11 +19,16 @@ afterEach(() => {
|
||||
|
||||
const selection: DomEditSelection = { id: "box", selector: "#box" } as DomEditSelection;
|
||||
|
||||
function successfulCommitMutation() {
|
||||
return vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({ ok: true }));
|
||||
}
|
||||
|
||||
function renderKeyframeOps(over: {
|
||||
commitMutation: (...args: unknown[]) => Promise<unknown>;
|
||||
trackGsapSaveFailure: (...args: unknown[]) => void;
|
||||
}) {
|
||||
const captured: { api: HookApi | null } = { api: null };
|
||||
// This hook harness intentionally mirrors the separate script-commit harness.
|
||||
function Probe() {
|
||||
// fallow-ignore-next-line code-duplication
|
||||
captured.api = useGsapKeyframeOps({
|
||||
@@ -51,9 +56,7 @@ function renderKeyframeOps(over: {
|
||||
|
||||
describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
||||
it("issues a resize-keyframed-tween mutation with the remap + window", async () => {
|
||||
const commitMutation = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
|
||||
ok: true,
|
||||
}));
|
||||
const commitMutation = successfulCommitMutation();
|
||||
const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>();
|
||||
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
|
||||
|
||||
@@ -102,10 +105,28 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
||||
});
|
||||
|
||||
describe("useGsapKeyframeOps — keyframe transaction options", () => {
|
||||
it("routes a flat-lane add through the add-keyframe writer mutation", async () => {
|
||||
const commitMutation = successfulCommitMutation();
|
||||
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
|
||||
|
||||
await act(async () => {
|
||||
await api.addKeyframeBatch(selection, "box-to-0-position", 50, { x: 210 });
|
||||
});
|
||||
|
||||
expect(commitMutation).toHaveBeenCalledWith(
|
||||
selection,
|
||||
{
|
||||
type: "add-keyframe",
|
||||
animationId: "box-to-0-position",
|
||||
percentage: 50,
|
||||
properties: { x: 210 },
|
||||
},
|
||||
{ label: "Add keyframe at 50%", softReload: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("soft-reloads a standalone convert when the SDK path is unavailable", async () => {
|
||||
const commitMutation = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
|
||||
ok: true,
|
||||
}));
|
||||
const commitMutation = successfulCommitMutation();
|
||||
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
|
||||
|
||||
await act(async () => {
|
||||
@@ -123,9 +144,7 @@ describe("useGsapKeyframeOps — keyframe transaction options", () => {
|
||||
});
|
||||
|
||||
it("threads one coalesce key through skipped convert reload and terminal batch edit", async () => {
|
||||
const commitMutation = vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({
|
||||
ok: true,
|
||||
}));
|
||||
const commitMutation = successfulCommitMutation();
|
||||
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() });
|
||||
const coalesceKey = "enable-keyframes:box-to-0-opacity:1";
|
||||
|
||||
|
||||
@@ -173,8 +173,8 @@ export function useGsapSelectionHandlers({
|
||||
);
|
||||
|
||||
const handleGsapDeleteAnimation = useCallback(
|
||||
(animId: string) => {
|
||||
const sel = domEditSelection ?? lastSelectionRef.current;
|
||||
(animId: string, selectionOverride?: DomEditSelection | null) => {
|
||||
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
||||
if (!sel) return;
|
||||
observeGsapMutation(deleteGsapAnimation(sel, animId), sel, "delete", "Delete GSAP animation");
|
||||
},
|
||||
@@ -298,17 +298,15 @@ export function useGsapSelectionHandlers({
|
||||
percentage: number,
|
||||
properties: Record<string, number | string>,
|
||||
commitOverrides?: Partial<CommitMutationOptions>,
|
||||
selectionOverride?: DomEditSelection | null,
|
||||
) => {
|
||||
if (!domEditSelection) return Promise.resolve();
|
||||
return addKeyframeBatch(
|
||||
domEditSelection,
|
||||
animId,
|
||||
percentage,
|
||||
properties,
|
||||
commitOverrides,
|
||||
).catch((error) => {
|
||||
trackGsapHandlerFailure(error, domEditSelection, "add-keyframe", "Add keyframe");
|
||||
});
|
||||
const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
||||
if (!sel) return Promise.resolve();
|
||||
return addKeyframeBatch(sel, animId, percentage, properties, commitOverrides).catch(
|
||||
(error) => {
|
||||
trackGsapHandlerFailure(error, sel, "add-keyframe", "Add keyframe");
|
||||
},
|
||||
);
|
||||
},
|
||||
[domEditSelection, addKeyframeBatch, trackGsapHandlerFailure],
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBrid
|
||||
import {
|
||||
clearKeyframeCacheForElement,
|
||||
clearKeyframeCacheForFile,
|
||||
writeGsapAnimationsForElement,
|
||||
} from "./gsapKeyframeCacheHelpers";
|
||||
import { toAbsoluteTime } from "./gsapShared";
|
||||
import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
|
||||
@@ -328,6 +329,12 @@ export function useGsapAnimationsForElement(
|
||||
// fallow-ignore-next-line complexity
|
||||
useEffect(() => {
|
||||
if (!elementId) return;
|
||||
const sourceAnimations = animations.filter(
|
||||
(animation) =>
|
||||
animation.propertyGroup && (animation.keyframes || synthesizeFlatTweenKeyframes(animation)),
|
||||
);
|
||||
if (sourceAnimations.length > 0)
|
||||
writeGsapAnimationsForElement(sourceFile, elementId, sourceAnimations);
|
||||
|
||||
// Resolve the element's time range from the player store so we can
|
||||
// convert tween-relative keyframe percentages to clip-relative ones.
|
||||
@@ -340,7 +347,11 @@ export function useGsapAnimationsForElement(
|
||||
);
|
||||
|
||||
const allKeyframes: Array<
|
||||
GsapKeyframesData["keyframes"][0] & { tweenPercentage?: number; propertyGroup?: string }
|
||||
GsapKeyframesData["keyframes"][0] & {
|
||||
tweenPercentage?: number;
|
||||
propertyGroup?: string;
|
||||
animationId?: string;
|
||||
}
|
||||
> = [];
|
||||
let format: GsapKeyframesData["format"] = "percentage";
|
||||
let ease: string | undefined;
|
||||
@@ -378,6 +389,7 @@ export function useGsapAnimationsForElement(
|
||||
percentage: clipPct,
|
||||
tweenPercentage: k.percentage,
|
||||
propertyGroup: anim.propertyGroup,
|
||||
animationId: anim.id,
|
||||
});
|
||||
}
|
||||
format = kf.format;
|
||||
@@ -459,6 +471,7 @@ export function usePopulateKeyframeCacheForFile(
|
||||
const { elements, domClipChildren } = usePlayerStore.getState();
|
||||
const doc = iframeRef?.current?.contentDocument;
|
||||
const mergedByElement = new Map<string, GsapKeyframesData>();
|
||||
const sourceByElement = new Map<string, GsapAnimation[]>();
|
||||
for (const anim of parsed.animations) {
|
||||
if (anim.hasUnresolvedKeyframes) continue;
|
||||
// Position-only static holds are not keyframed animations — skip them so
|
||||
@@ -479,12 +492,16 @@ export function usePopulateKeyframeCacheForFile(
|
||||
// Attribute the tween to every element it animates (handles class /
|
||||
// group / descendant selectors, not just `#id`).
|
||||
for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) {
|
||||
// kfData is already resolved (real keyframes OR a synthesized flat
|
||||
// tween), so a grouped flat tween joins the store like a keyframed one.
|
||||
if (anim.propertyGroup) {
|
||||
sourceByElement.set(id, [...(sourceByElement.get(id) ?? []), anim]);
|
||||
}
|
||||
const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren);
|
||||
const clipKeyframes = kfData.keyframes.map((kf) => {
|
||||
const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage);
|
||||
// 0.001% precision (matching useGsapAnimationsForElement above) so a
|
||||
// beat-snapped keyframe centers exactly on the beat dot and the two
|
||||
// caches agree on a keyframe's percentage.
|
||||
// 0.001% precision (see useGsapAnimationsForElement) so a beat-snapped
|
||||
// keyframe centers on the beat dot and both caches agree.
|
||||
const clipPct =
|
||||
elDuration > 0
|
||||
? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000
|
||||
@@ -494,6 +511,7 @@ export function usePopulateKeyframeCacheForFile(
|
||||
percentage: clipPct,
|
||||
tweenPercentage: kf.percentage,
|
||||
propertyGroup: anim.propertyGroup,
|
||||
animationId: anim.id, // parity with other cache writers; inline ease needs it
|
||||
};
|
||||
});
|
||||
const existing = mergedByElement.get(id);
|
||||
@@ -508,6 +526,7 @@ export function usePopulateKeyframeCacheForFile(
|
||||
setKeyframeCache(`${sf}#${id}`, kfData);
|
||||
setKeyframeCache(id, kfData);
|
||||
if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, kfData);
|
||||
writeGsapAnimationsForElement(sf, id, sourceByElement.get(id));
|
||||
}
|
||||
astFetchDoneRef.current = fetchKey;
|
||||
});
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from "react";
|
||||
import type { DomEditSelection } from "../components/editor/domEditing";
|
||||
|
||||
interface StudioTestHookDeps {
|
||||
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
|
||||
buildDomSelectionFromTarget: (target: HTMLElement) => Promise<DomEditSelection | null>;
|
||||
applyDomSelection: (
|
||||
selection: DomEditSelection | null,
|
||||
options?: { revealPanel?: boolean },
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-only headless-QA shortcut. Selecting an element normally requires a
|
||||
* pixel-precise click inside the preview iframe, which automated verification
|
||||
* can't reliably land. `window.__studioTest.selectByDomId(id)` resolves the
|
||||
* DomEditSelection for a preview element by id and reveals the inspector —
|
||||
* exactly what a click does — so a driver can open the property/ease panels and
|
||||
* then focus a segment via `__playerStore.getState().setFocusedEaseSegment`.
|
||||
* No-op in production builds.
|
||||
*/
|
||||
export function useStudioTestHooks({
|
||||
previewIframeRef,
|
||||
buildDomSelectionFromTarget,
|
||||
applyDomSelection,
|
||||
}: StudioTestHookDeps): void {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
let isDev = false;
|
||||
try {
|
||||
isDev = import.meta.env.DEV === true;
|
||||
} catch {
|
||||
isDev = false;
|
||||
}
|
||||
if (!isDev || typeof window === "undefined") return;
|
||||
const api = {
|
||||
selectByDomId: async (id: string): Promise<boolean> => {
|
||||
const element = previewIframeRef.current?.contentDocument?.getElementById(id) ?? null;
|
||||
if (!element) return false;
|
||||
const selection = await buildDomSelectionFromTarget(element);
|
||||
if (!selection) return false;
|
||||
applyDomSelection(selection, { revealPanel: true });
|
||||
return true;
|
||||
},
|
||||
};
|
||||
(window as unknown as { __studioTest?: typeof api }).__studioTest = api;
|
||||
return () => {
|
||||
(window as unknown as { __studioTest?: typeof api }).__studioTest = undefined;
|
||||
};
|
||||
}, [applyDomSelection, buildDomSelectionFromTarget, previewIframeRef]);
|
||||
}
|
||||
Reference in New Issue
Block a user