mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
feat(studio): add keyframe timeline state
This commit is contained in:
@@ -45,17 +45,22 @@ function selectPreset(host: HTMLElement, presetId: string): string {
|
||||
return presetConfig.ease;
|
||||
}
|
||||
|
||||
function renderExpandedCard({
|
||||
animation,
|
||||
/** Every test mounts the same card; only expansion, flat mode, and the spies differ. */
|
||||
function renderCard({
|
||||
animation = baseAnimation(),
|
||||
defaultExpanded = true,
|
||||
flat,
|
||||
onUpdateMeta = vi.fn(),
|
||||
onUpdateKeyframeEase = vi.fn(),
|
||||
onDeleteAnimation = noop,
|
||||
}: {
|
||||
animation: GsapAnimation;
|
||||
animation?: GsapAnimation;
|
||||
defaultExpanded?: boolean;
|
||||
flat?: boolean;
|
||||
onUpdateMeta?: ReturnType<typeof vi.fn>;
|
||||
onUpdateKeyframeEase?: ReturnType<typeof vi.fn>;
|
||||
}) {
|
||||
onDeleteAnimation?: (id: string) => void;
|
||||
} = {}) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
@@ -63,11 +68,11 @@ function renderExpandedCard({
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={animation}
|
||||
defaultExpanded
|
||||
defaultExpanded={defaultExpanded}
|
||||
flat={flat}
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={onUpdateMeta}
|
||||
onDeleteAnimation={noop}
|
||||
onDeleteAnimation={onDeleteAnimation}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
onUpdateKeyframeEase={onUpdateKeyframeEase}
|
||||
@@ -90,7 +95,7 @@ describe("AnimationCard ease editing", () => {
|
||||
],
|
||||
},
|
||||
});
|
||||
const view = renderExpandedCard({ animation, onUpdateKeyframeEase });
|
||||
const view = renderCard({ animation, onUpdateKeyframeEase });
|
||||
|
||||
const segment = Array.from(view.host.querySelectorAll("button")).find((button) =>
|
||||
button.textContent?.includes("0% → 50%"),
|
||||
@@ -101,6 +106,7 @@ describe("AnimationCard ease editing", () => {
|
||||
|
||||
expect(onUpdateKeyframeEase).toHaveBeenCalledExactlyOnceWith(animation.id, 50, ease);
|
||||
expect(trackStudioSegmentEaseEdit).toHaveBeenCalledExactlyOnceWith({
|
||||
action: "commit",
|
||||
ease,
|
||||
});
|
||||
act(() => view.root.unmount());
|
||||
@@ -110,7 +116,7 @@ describe("AnimationCard ease editing", () => {
|
||||
const onUpdateMeta = vi.fn();
|
||||
const onUpdateKeyframeEase = vi.fn();
|
||||
const animation = baseAnimation({ id: "flat-tween" });
|
||||
const view = renderExpandedCard({
|
||||
const view = renderCard({
|
||||
animation,
|
||||
flat: true,
|
||||
onUpdateMeta,
|
||||
@@ -128,23 +134,7 @@ describe("AnimationCard ease editing", () => {
|
||||
|
||||
describe("AnimationCard flat branch", () => {
|
||||
it("renders a mint border-left and panel-token colors when flat", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={baseAnimation()}
|
||||
defaultExpanded={false}
|
||||
flat
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={noop}
|
||||
onDeleteAnimation={noop}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { host, root } = renderCard({ defaultExpanded: false, flat: true });
|
||||
const card = host.querySelector('[data-flat-effect-card="true"]');
|
||||
expect(card).not.toBeNull();
|
||||
expect(card?.className).toContain("border-panel-accent");
|
||||
@@ -152,22 +142,7 @@ describe("AnimationCard flat branch", () => {
|
||||
});
|
||||
|
||||
it("still renders the legacy (non-flat) appearance when flat is omitted", () => {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={baseAnimation()}
|
||||
defaultExpanded={false}
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={noop}
|
||||
onDeleteAnimation={noop}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { host, root } = renderCard({ defaultExpanded: false });
|
||||
expect(host.querySelector('[data-flat-effect-card="true"]')).toBeNull();
|
||||
expect(host.textContent).toContain("power2.out");
|
||||
act(() => root.unmount());
|
||||
@@ -175,23 +150,7 @@ describe("AnimationCard flat branch", () => {
|
||||
|
||||
it("toggles expanded state when the collapsed header button is clicked, in both modes", () => {
|
||||
for (const flat of [false, true]) {
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={baseAnimation()}
|
||||
defaultExpanded={false}
|
||||
flat={flat || undefined}
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={noop}
|
||||
onDeleteAnimation={noop}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { host, root } = renderCard({ defaultExpanded: false, flat: flat || undefined });
|
||||
expect(host.textContent).not.toContain("Remove");
|
||||
const button = host.querySelector("button");
|
||||
expect(button).not.toBeNull();
|
||||
@@ -205,23 +164,7 @@ describe("AnimationCard flat branch", () => {
|
||||
|
||||
it("invokes onDeleteAnimation with the animation id when Remove is clicked, in flat mode", () => {
|
||||
const onDeleteAnimation = vi.fn();
|
||||
const host = document.createElement("div");
|
||||
document.body.append(host);
|
||||
const root = createRoot(host);
|
||||
act(() => {
|
||||
root.render(
|
||||
<AnimationCard
|
||||
animation={baseAnimation()}
|
||||
defaultExpanded={true}
|
||||
flat
|
||||
onUpdateProperty={noop}
|
||||
onUpdateMeta={noop}
|
||||
onDeleteAnimation={onDeleteAnimation}
|
||||
onAddProperty={noop}
|
||||
onRemoveProperty={noop}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { host, root } = renderCard({ flat: true, onDeleteAnimation });
|
||||
const buttons = Array.from(host.querySelectorAll("button"));
|
||||
const removeButton = buttons.find((b) => b.textContent === "Remove");
|
||||
expect(removeButton).not.toBeUndefined();
|
||||
|
||||
@@ -267,7 +267,7 @@ export const AnimationCard = memo(function AnimationCard({
|
||||
onToggle={setExpandedKfPct}
|
||||
onEaseCommit={(pct, ease) => {
|
||||
onUpdateKeyframeEase(animation.id, pct, ease);
|
||||
trackStudioSegmentEaseEdit({ ease });
|
||||
trackStudioSegmentEaseEdit({ action: "commit", ease });
|
||||
}}
|
||||
onApplyAll={
|
||||
onSetAllKeyframeEases
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { StoreApi } from "zustand";
|
||||
|
||||
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
|
||||
export interface KeyframeCacheEntry {
|
||||
format: string;
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
/** Original tween-relative percentage (server mutations need this, not the clip-relative `percentage`). */
|
||||
tweenPercentage?: number;
|
||||
/** Which property group the source tween belongs to (position, scale, rotation, visual, etc.). */
|
||||
propertyGroup?: string;
|
||||
/** Source tween id — lets the inline clip-row ease button target a specific segment. */
|
||||
animationId?: string;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
/** Set when 2+ source animations collide at this percentage (a single inline
|
||||
* ease button can't target one): the collapsed row hides the button here. */
|
||||
easeAmbiguous?: boolean;
|
||||
}>;
|
||||
ease?: string;
|
||||
easeEach?: string;
|
||||
}
|
||||
|
||||
export interface KeyframeSlice {
|
||||
/** Selected collapsed (`element:pct`) or expanded (`element:group:animation:clipPct`) diamonds. */
|
||||
selectedKeyframes: Set<string>;
|
||||
toggleSelectedKeyframe: (key: string) => void;
|
||||
clearSelectedKeyframes: () => void;
|
||||
|
||||
/** Clips whose keyframe property lanes are expanded in the timeline. */
|
||||
expandedClipIds: Set<string>;
|
||||
toggleClipExpanded: (id: string) => void;
|
||||
setClipExpanded: (id: string, expanded: boolean) => void;
|
||||
/** Union-expand clips (keyframed clips are expanded by default on load). */
|
||||
expandClips: (ids: readonly string[]) => void;
|
||||
|
||||
/** elementId scopes the request to one element so a shared (class-selector)
|
||||
* animation id can't open the ease editor on the wrong element. */
|
||||
focusedEaseSegment: { animationId: string; tweenPercentage: number; elementId: string } | null;
|
||||
setFocusedEaseSegment: (
|
||||
target: { animationId: string; tweenPercentage: number; elementId: string } | null,
|
||||
) => void;
|
||||
|
||||
/** Keyframe data per element id, populated from parsed GSAP animations. */
|
||||
keyframeCache: Map<string, KeyframeCacheEntry>;
|
||||
/** Unmerged source tweens per element; expanded property lanes read this, never keyframeCache. */
|
||||
gsapAnimations: Map<string, GsapAnimation[]>;
|
||||
setGsapAnimations: (elementId: string, animations: GsapAnimation[] | undefined) => void;
|
||||
setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void;
|
||||
}
|
||||
|
||||
export function createKeyframeSlice(set: StoreApi<KeyframeSlice>["setState"]): KeyframeSlice {
|
||||
return {
|
||||
selectedKeyframes: new Set(),
|
||||
toggleSelectedKeyframe: (key) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.selectedKeyframes);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return { selectedKeyframes: next };
|
||||
}),
|
||||
clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }),
|
||||
|
||||
expandedClipIds: new Set(),
|
||||
toggleClipExpanded: (id) =>
|
||||
set((state) => {
|
||||
const next = new Set(state.expandedClipIds);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return { expandedClipIds: next };
|
||||
}),
|
||||
setClipExpanded: (id, expanded) =>
|
||||
set((state) => {
|
||||
if (state.expandedClipIds.has(id) === expanded) return state;
|
||||
const next = new Set(state.expandedClipIds);
|
||||
if (expanded) next.add(id);
|
||||
else next.delete(id);
|
||||
return { expandedClipIds: next };
|
||||
}),
|
||||
expandClips: (ids) =>
|
||||
set((state) => {
|
||||
if (ids.every((id) => state.expandedClipIds.has(id))) return state;
|
||||
const next = new Set(state.expandedClipIds);
|
||||
for (const id of ids) next.add(id);
|
||||
return { expandedClipIds: next };
|
||||
}),
|
||||
|
||||
focusedEaseSegment: null,
|
||||
setFocusedEaseSegment: (target) => set({ focusedEaseSegment: target }),
|
||||
|
||||
keyframeCache: new Map(),
|
||||
setKeyframeCache: (elementId, data) =>
|
||||
set((state) => {
|
||||
const next = new Map(state.keyframeCache);
|
||||
if (data) next.set(elementId, data);
|
||||
else next.delete(elementId);
|
||||
return { keyframeCache: next };
|
||||
}),
|
||||
gsapAnimations: new Map(),
|
||||
setGsapAnimations: (elementId, animations) =>
|
||||
set((state) => {
|
||||
const next = new Map(state.gsapAnimations);
|
||||
if (animations) next.set(elementId, animations);
|
||||
else next.delete(elementId);
|
||||
return { gsapAnimations: next };
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -25,6 +25,31 @@ describe("usePlayerStore", () => {
|
||||
expect(state.loopEnabled).toBe(false);
|
||||
expect(state.zoomMode).toBe("fit");
|
||||
expect(state.manualZoomPercent).toBe(100);
|
||||
expect(state.expandedClipIds).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandedClipIds", () => {
|
||||
it("toggles clip membership", () => {
|
||||
const store = usePlayerStore.getState();
|
||||
|
||||
store.toggleClipExpanded("clip-1");
|
||||
expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"]));
|
||||
|
||||
store.toggleClipExpanded("clip-1");
|
||||
expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("sets clip membership idempotently", () => {
|
||||
const store = usePlayerStore.getState();
|
||||
|
||||
store.setClipExpanded("clip-1", true);
|
||||
store.setClipExpanded("clip-1", true);
|
||||
expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(["clip-1"]));
|
||||
|
||||
store.setClipExpanded("clip-1", false);
|
||||
store.setClipExpanded("clip-1", false);
|
||||
expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4,22 +4,9 @@ import type { BeatEditState } from "../../utils/beatEditing";
|
||||
import type { ClipManifestClip } from "../lib/playbackTypes";
|
||||
import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences";
|
||||
import { computePinnedZoomPercent } from "../components/timelineZoom";
|
||||
import { createKeyframeSlice, type KeyframeSlice } from "./keyframeSlice";
|
||||
|
||||
/** Minimal keyframe cache types — mirrors GsapKeyframesData without pulling in Node-only gsap-parser. */
|
||||
export interface KeyframeCacheEntry {
|
||||
format: string;
|
||||
keyframes: Array<{
|
||||
percentage: number;
|
||||
/** Original tween-relative percentage (server mutations need this, not the clip-relative `percentage`). */
|
||||
tweenPercentage?: number;
|
||||
/** Which property group the source tween belongs to (position, scale, rotation, visual, etc.). */
|
||||
propertyGroup?: string;
|
||||
properties: Record<string, number | string>;
|
||||
ease?: string;
|
||||
}>;
|
||||
ease?: string;
|
||||
easeEach?: string;
|
||||
}
|
||||
export type { KeyframeCacheEntry } from "./keyframeSlice";
|
||||
|
||||
export interface TimelineElement {
|
||||
id: string;
|
||||
@@ -109,7 +96,7 @@ function resolveElementSelection(
|
||||
};
|
||||
}
|
||||
|
||||
interface PlayerState {
|
||||
interface PlayerState extends KeyframeSlice {
|
||||
isPlaying: boolean;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
@@ -140,11 +127,6 @@ interface PlayerState {
|
||||
activeTool: TimelineTool;
|
||||
setActiveTool: (tool: TimelineTool) => void;
|
||||
|
||||
/** Set of selected keyframe keys in format `${elementId}:${percentage}`. */
|
||||
selectedKeyframes: Set<string>;
|
||||
toggleSelectedKeyframe: (key: string) => void;
|
||||
clearSelectedKeyframes: () => void;
|
||||
|
||||
/** Tween-relative percentage of the last-clicked keyframe diamond. Operations
|
||||
* (drag, resize, rotate) target this instead of recomputing from playhead. */
|
||||
activeKeyframePct: number | null;
|
||||
@@ -193,10 +175,6 @@ interface PlayerState {
|
||||
toggleSelectedElementId: (id: string) => void;
|
||||
clearSelection: () => void;
|
||||
|
||||
/** Keyframe data per element id, populated from parsed GSAP animations. */
|
||||
keyframeCache: Map<string, KeyframeCacheEntry>;
|
||||
setKeyframeCache: (elementId: string, data: KeyframeCacheEntry | undefined) => void;
|
||||
|
||||
setIsPlaying: (playing: boolean) => void;
|
||||
setCurrentTime: (time: number) => void;
|
||||
setDuration: (duration: number) => void;
|
||||
@@ -327,15 +305,7 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
activeTool: "select",
|
||||
setActiveTool: (tool) => set({ activeTool: tool }),
|
||||
|
||||
selectedKeyframes: new Set(),
|
||||
toggleSelectedKeyframe: (key) =>
|
||||
set((s) => {
|
||||
const next = new Set(s.selectedKeyframes);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return { selectedKeyframes: next };
|
||||
}),
|
||||
clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }),
|
||||
...createKeyframeSlice(set),
|
||||
|
||||
activeKeyframePct: null,
|
||||
setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }),
|
||||
@@ -363,15 +333,6 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
}),
|
||||
clearSelection: () => set({ selectedElementId: null, selectedElementIds: new Set() }),
|
||||
|
||||
keyframeCache: new Map(),
|
||||
setKeyframeCache: (elementId, data) =>
|
||||
set((s) => {
|
||||
const next = new Map(s.keyframeCache);
|
||||
if (data) next.set(elementId, data);
|
||||
else next.delete(elementId);
|
||||
return { keyframeCache: next };
|
||||
}),
|
||||
|
||||
requestedSeekTime: null,
|
||||
requestSeek: (time) => set({ requestedSeekTime: time }),
|
||||
clearSeekRequest: () => set({ requestedSeekTime: null }),
|
||||
@@ -569,9 +530,11 @@ export const usePlayerStore = create<PlayerState>((set, get) => ({
|
||||
outPoint: null,
|
||||
activeTool: "select",
|
||||
selectedKeyframes: new Set(),
|
||||
expandedClipIds: new Set(),
|
||||
selectedElementIds: new Set(),
|
||||
clipRevealRequest: null,
|
||||
keyframeCache: new Map(),
|
||||
gsapAnimations: new Map(),
|
||||
beatAnalysis: null,
|
||||
beatEdits: null,
|
||||
beatUndo: [],
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
trackStudioRenderStart,
|
||||
trackStudioRazorSplit,
|
||||
trackStudioExpandedClipEdit,
|
||||
trackStudioKeyframeLaneExpand,
|
||||
trackStudioSegmentEaseEdit,
|
||||
trackStudioFeedback,
|
||||
} = await import("./events");
|
||||
@@ -72,8 +73,13 @@ describe("studio telemetry events", () => {
|
||||
expect(trackEvent).toHaveBeenCalledWith("studio_expanded_clip_edit", { action: "resize" });
|
||||
});
|
||||
|
||||
it("trackStudioKeyframeLaneExpand emits 'studio_keyframe_lane_expand' with expanded", () => {
|
||||
trackStudioKeyframeLaneExpand({ expanded: true });
|
||||
expect(trackEvent).toHaveBeenCalledWith("studio_keyframe_lane_expand", { expanded: true });
|
||||
});
|
||||
|
||||
it("trackStudioSegmentEaseEdit emits 'studio_segment_ease_edit' with action and ease", () => {
|
||||
trackStudioSegmentEaseEdit({ ease: "power2.out" });
|
||||
trackStudioSegmentEaseEdit({ action: "commit", ease: "power2.out" });
|
||||
expect(trackEvent).toHaveBeenCalledWith("studio_segment_ease_edit", {
|
||||
action: "commit",
|
||||
ease: "power2.out",
|
||||
|
||||
@@ -63,9 +63,17 @@ export function trackStudioExpandedClipEdit(props: {
|
||||
trackEvent("studio_expanded_clip_edit", { action: props.action });
|
||||
}
|
||||
|
||||
// Adoption signal for committing an edit to a segment's ease.
|
||||
export function trackStudioSegmentEaseEdit(props: { ease: string }): void {
|
||||
trackEvent("studio_segment_ease_edit", { action: "commit", ease: props.ease });
|
||||
// Adoption signal for the per-clip keyframe-lane caret toggle.
|
||||
export function trackStudioKeyframeLaneExpand(props: { expanded: boolean }): void {
|
||||
trackEvent("studio_keyframe_lane_expand", { expanded: props.expanded });
|
||||
}
|
||||
|
||||
// Adoption signal for opening and committing the per-segment ease editor.
|
||||
export function trackStudioSegmentEaseEdit(props: {
|
||||
action: "open" | "commit";
|
||||
ease?: string;
|
||||
}): void {
|
||||
trackEvent("studio_segment_ease_edit", { action: props.action, ease: props.ease });
|
||||
}
|
||||
|
||||
export function trackStudioFeedback(props: { rating: number; comment?: string }): void {
|
||||
|
||||
Reference in New Issue
Block a user