mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix(studio): harden keyframe editing semantics
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection";
|
||||
import { timelineKeyframeTargetFromSelectionKey } from "../player/components/timelineKeyframeIdentity";
|
||||
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
||||
|
||||
let deleteKeyframesCommitCounter = 0;
|
||||
@@ -18,18 +18,31 @@ export function deleteSelectedKeyframes(session: {
|
||||
) => void;
|
||||
}): void {
|
||||
const { selectedKeyframes, selectedElementId } = usePlayerStore.getState();
|
||||
const animation = session.selectedGsapAnimations.find((anim) => anim.keyframes);
|
||||
if (!animation) return;
|
||||
// Only the active element's keyframes; a stale cross-element selection must not delete here.
|
||||
const percentages = selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId);
|
||||
if (!selectedElementId) return;
|
||||
const keyframedAnimations = session.selectedGsapAnimations.filter((anim) => anim.keyframes);
|
||||
const fallbackAnimation = keyframedAnimations[0];
|
||||
const animationsById = new Map(keyframedAnimations.map((animation) => [animation.id, animation]));
|
||||
const removals = new Map<string, { animationId: string; percentage: number }>();
|
||||
for (const key of selectedKeyframes) {
|
||||
const target = timelineKeyframeTargetFromSelectionKey(selectedElementId, key);
|
||||
if (!target) continue;
|
||||
const animation = target.animationId
|
||||
? animationsById.get(target.animationId)
|
||||
: fallbackAnimation;
|
||||
if (!animation) continue;
|
||||
const percentage = target.tweenPercentage ?? target.percentage;
|
||||
removals.set(`${animation.id}\0${percentage}`, { animationId: animation.id, percentage });
|
||||
}
|
||||
const targets = [...removals.values()];
|
||||
if (targets.length === 0) return;
|
||||
const coalesceOptions = {
|
||||
coalesceKey: `delete-keyframes:${++deleteKeyframesCommitCounter}`,
|
||||
coalesceMs: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
for (const [index, pct] of percentages.entries()) {
|
||||
session.handleGsapRemoveKeyframe(animation.id, pct, {
|
||||
for (const [index, target] of targets.entries()) {
|
||||
session.handleGsapRemoveKeyframe(target.animationId, target.percentage, {
|
||||
...coalesceOptions,
|
||||
...(index === percentages.length - 1 ? { softReload: true } : { skipReload: true }),
|
||||
...(index === targets.length - 1 ? { softReload: true } : { skipReload: true }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,21 @@ import {
|
||||
materializeIfDynamic,
|
||||
} from "./gsapDragCommit";
|
||||
|
||||
export function buildTemporalArcKeyframes(
|
||||
anim: GsapAnimation,
|
||||
percentage: number,
|
||||
properties: Record<string, number>,
|
||||
) {
|
||||
return [
|
||||
...(anim.keyframes?.keyframes ?? []).map((keyframe) => ({
|
||||
percentage: keyframe.percentage,
|
||||
properties: { ...keyframe.properties },
|
||||
...(keyframe.ease ? { ease: keyframe.ease } : {}),
|
||||
})),
|
||||
{ percentage, properties },
|
||||
].sort((a, b) => a.percentage - b.percentage);
|
||||
}
|
||||
|
||||
async function extendTweenAndAddKeyframe(
|
||||
selection: DomEditSelection,
|
||||
anim: GsapAnimation,
|
||||
@@ -259,6 +274,47 @@ export async function commitGsapPositionFromDrag(
|
||||
|
||||
const backfillDefaults: Record<string, number> = { x: baseGsapX, y: baseGsapY };
|
||||
const ct = usePlayerStore.getState().currentTime;
|
||||
if (anim.arcPath?.enabled) {
|
||||
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
||||
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
||||
const keyframes = anim.keyframes?.keyframes ?? [];
|
||||
const pointIndex = keyframes.findIndex((kf) => Math.abs(kf.percentage - pct) < 0.05);
|
||||
if (pointIndex >= 0) {
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "update-motion-path-point",
|
||||
animationId: anim.id,
|
||||
pointIndex,
|
||||
x: newX,
|
||||
y: newY,
|
||||
},
|
||||
{ label: "Move layer (waypoint)", softReload: true, beforeReload: restoreOffset },
|
||||
);
|
||||
setActiveKeyframePct(null);
|
||||
parkPlayheadOnKeyframe(anim, pct);
|
||||
return;
|
||||
}
|
||||
|
||||
const tweenStart = resolveTweenStart(anim);
|
||||
const tweenDuration = resolveTweenDuration(anim);
|
||||
if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2) return;
|
||||
const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY });
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: anim.id,
|
||||
targetSelector: anim.targetSelector,
|
||||
position: roundTo3(tweenStart),
|
||||
duration: roundTo3(tweenDuration),
|
||||
keyframes: temporalKeyframes,
|
||||
ease: "none",
|
||||
},
|
||||
{ label: "Move layer (new keyframe)", softReload: true, beforeReload: restoreOffset },
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (anim.keyframes) {
|
||||
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
||||
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
|
||||
|
||||
@@ -280,3 +280,103 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => {
|
||||
expect(types).not.toContain("replace-with-keyframes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tryGsapDragIntercept — motion paths", () => {
|
||||
const motionPathAnim = {
|
||||
id: "#puck-b-to-12170-position",
|
||||
targetSelector: "#puck-b",
|
||||
propertyGroup: "position",
|
||||
method: "to",
|
||||
position: 12.17,
|
||||
resolvedStart: 12.17,
|
||||
duration: 16.055,
|
||||
ease: "power1.inOut",
|
||||
properties: {},
|
||||
keyframes: {
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: -184, y: 326 } },
|
||||
{ percentage: 50, properties: { x: 416, y: 804 } },
|
||||
{ percentage: 100, properties: { x: 796, y: 237 } },
|
||||
],
|
||||
},
|
||||
arcPath: {
|
||||
enabled: true,
|
||||
autoRotate: false,
|
||||
segments: [{ curviness: 1 }, { curviness: 1 }],
|
||||
},
|
||||
} as unknown as GsapAnimation;
|
||||
const liveTween = {
|
||||
targets: () => [{ id: "puck-b" }],
|
||||
vars: { motionPath: { path: [] }, duration: 16.055 },
|
||||
duration: () => 16.055,
|
||||
startTime: () => 12.17,
|
||||
};
|
||||
|
||||
async function dragMotionPath(activeKeyframePct: number | null) {
|
||||
usePlayerStore.setState({
|
||||
autoKeyframeEnabled: true,
|
||||
activeKeyframePct,
|
||||
currentTime: 15.9,
|
||||
});
|
||||
const commitMutation = vi.fn();
|
||||
const handled = await tryGsapDragIntercept(
|
||||
selection,
|
||||
{ x: -50, y: 30 },
|
||||
[motionPathAnim],
|
||||
fakeIframe("puck-b", [liveTween]),
|
||||
commitMutation,
|
||||
);
|
||||
return { commitMutation, handled };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
usePlayerStore.setState({ activeKeyframePct: null });
|
||||
});
|
||||
|
||||
it("creates a temporal keyframe at the exact playhead instead of redistributing path waypoints", async () => {
|
||||
const { commitMutation, handled } = await dragMotionPath(null);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(commitMutation).toHaveBeenCalledWith(
|
||||
selection,
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: motionPathAnim.id,
|
||||
targetSelector: "#puck-b",
|
||||
position: 12.17,
|
||||
duration: 16.055,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: -184, y: 326 } },
|
||||
{ percentage: 23.233, properties: { x: -50, y: 30 } },
|
||||
{ percentage: 50, properties: { x: 416, y: 804 } },
|
||||
{ percentage: 100, properties: { x: 796, y: 237 } },
|
||||
],
|
||||
ease: "none",
|
||||
},
|
||||
expect.objectContaining({ label: "Move layer (new keyframe)", softReload: true }),
|
||||
);
|
||||
expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain(
|
||||
"add-motion-path-point",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an explicitly selected path waypoint as a spatial edit", async () => {
|
||||
const { commitMutation, handled } = await dragMotionPath(50);
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(commitMutation).toHaveBeenCalledWith(
|
||||
selection,
|
||||
{
|
||||
type: "update-motion-path-point",
|
||||
animationId: motionPathAnim.id,
|
||||
pointIndex: 1,
|
||||
x: -50,
|
||||
y: 30,
|
||||
},
|
||||
expect.objectContaining({ label: "Move layer (waypoint)", softReload: true }),
|
||||
);
|
||||
expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain(
|
||||
"replace-with-keyframes",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface MutationResult {
|
||||
|
||||
export interface CommitMutationOptions {
|
||||
label: string;
|
||||
/** Observe the durable writer result without duplicating the request path. */
|
||||
onResult?: (result: MutationResult) => void;
|
||||
coalesceKey?: string;
|
||||
coalesceMs?: number;
|
||||
softReload?: boolean;
|
||||
|
||||
@@ -1,14 +1,30 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import {
|
||||
idFromSelector,
|
||||
idSelector,
|
||||
isInstantHold,
|
||||
parsePercentageKeyframes,
|
||||
resolveEditableTweenDuration,
|
||||
toClipKeyframes,
|
||||
toClipPercentage,
|
||||
} from "./gsapShared";
|
||||
|
||||
describe("resolveEditableTweenDuration", () => {
|
||||
const selection = { dataAttributes: { duration: "16.26" } } as DomEditSelection;
|
||||
|
||||
it("uses the owning clip duration when the tween omits an outer duration", () => {
|
||||
expect(resolveEditableTweenDuration({ duration: undefined } as GsapAnimation, selection)).toBe(
|
||||
16.26,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an explicitly-authored tween duration", () => {
|
||||
expect(resolveEditableTweenDuration({ duration: 4 } as GsapAnimation, selection)).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isInstantHold", () => {
|
||||
const animation = (method: GsapAnimation["method"], duration?: number) =>
|
||||
({ method, duration }) as unknown as GsapAnimation;
|
||||
|
||||
@@ -109,6 +109,22 @@ export function selectorFromSelection(selection: DomEditSelection): string | nul
|
||||
|
||||
// ── Percentage computation ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the timing basis used by editor keyframes. The timeline renders a
|
||||
* duration-less tween across its owning clip, so mutations must use that same
|
||||
* duration instead of silently falling back to GSAP's 0.5s default.
|
||||
*/
|
||||
export function resolveEditableTweenDuration(
|
||||
animation: GsapAnimation,
|
||||
selection: DomEditSelection,
|
||||
): number {
|
||||
const clipDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "");
|
||||
return resolveTweenDuration(
|
||||
animation,
|
||||
Number.isFinite(clipDuration) && clipDuration > 0 ? clipDuration : 0.5,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the current playback percentage within an element's animation range.
|
||||
* Uses the animation's resolved timing if available, otherwise falls back to
|
||||
@@ -121,7 +137,7 @@ export function computeElementPercentage(
|
||||
): number {
|
||||
if (animation) {
|
||||
const start = resolveTweenStart(animation);
|
||||
const duration = resolveTweenDuration(animation);
|
||||
const duration = resolveEditableTweenDuration(animation, selection);
|
||||
if (duration <= 0) return 0;
|
||||
if (start !== null) {
|
||||
return absoluteToPercentage(currentTime, start, duration);
|
||||
@@ -129,9 +145,7 @@ export function computeElementPercentage(
|
||||
}
|
||||
const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0;
|
||||
const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1;
|
||||
return elDuration > 0
|
||||
? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10))
|
||||
: 0;
|
||||
return absoluteToPercentage(currentTime, elStart, elDuration);
|
||||
}
|
||||
|
||||
// ── Iframe accessors ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import type { TimelineElement } from "../player/store/playerStore";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
||||
import { timelineKeyframeSelectionKey } from "../player/components/timelineKeyframeIdentity";
|
||||
|
||||
afterEach(() => {
|
||||
usePlayerStore.getState().reset();
|
||||
@@ -342,4 +343,51 @@ describe("deleteSelectedKeyframes", () => {
|
||||
expect(options[1]).not.toHaveProperty("softReload");
|
||||
expect(options[2]).not.toHaveProperty("skipReload");
|
||||
});
|
||||
|
||||
it("deletes two expanded lanes through their own animation and tween percentages", () => {
|
||||
usePlayerStore.setState({
|
||||
selectedElementId: "card",
|
||||
selectedKeyframes: new Set([
|
||||
timelineKeyframeSelectionKey("card", {
|
||||
percentage: 30,
|
||||
tweenPercentage: 20,
|
||||
propertyGroup: "position",
|
||||
animationId: "card-position",
|
||||
}),
|
||||
timelineKeyframeSelectionKey("card", {
|
||||
percentage: 70,
|
||||
tweenPercentage: 80,
|
||||
propertyGroup: "visual",
|
||||
animationId: "card-visual",
|
||||
}),
|
||||
]),
|
||||
});
|
||||
const handleGsapRemoveKeyframe =
|
||||
vi.fn<(animId: string, pct: number, options?: Partial<CommitMutationOptions>) => void>();
|
||||
|
||||
deleteSelectedKeyframes({
|
||||
selectedGsapAnimations: [
|
||||
{ id: "card-position", keyframes: {} },
|
||||
{ id: "card-visual", keyframes: {} },
|
||||
],
|
||||
handleGsapRemoveKeyframe,
|
||||
});
|
||||
|
||||
expect(handleGsapRemoveKeyframe).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
handleGsapRemoveKeyframe.mock.calls.map(([animationId, percentage]) => [
|
||||
animationId,
|
||||
percentage,
|
||||
]),
|
||||
).toEqual([
|
||||
["card-position", 20],
|
||||
["card-visual", 80],
|
||||
]);
|
||||
expect(handleGsapRemoveKeyframe.mock.calls[0]?.[2]).toEqual(
|
||||
expect.objectContaining({ skipReload: true }),
|
||||
);
|
||||
expect(handleGsapRemoveKeyframe.mock.calls[1]?.[2]).toEqual(
|
||||
expect.objectContaining({ softReload: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,14 +92,14 @@ export interface UseDomEditWiringParams {
|
||||
animId: string,
|
||||
fromPercentage: number,
|
||||
toPercentage: number,
|
||||
) => void;
|
||||
) => Promise<boolean>;
|
||||
resizeKeyframedTween: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
pctRemap: Array<{ from: number; to: number }>,
|
||||
) => void;
|
||||
) => Promise<boolean>;
|
||||
convertToKeyframes: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import {
|
||||
applyArcKeyframeAtPlayhead,
|
||||
animatedProps,
|
||||
buildExtendedKeyframes,
|
||||
isPlayheadWithinTween,
|
||||
@@ -219,6 +220,114 @@ describe("promoteSetToKeyframes — auto endpoint", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyArcKeyframeAtPlayhead", () => {
|
||||
const arcAnim = anim({
|
||||
id: "#el-to-0-position",
|
||||
position: 0,
|
||||
duration: 10,
|
||||
keyframes: {
|
||||
format: "object-array",
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||
{ percentage: 50, properties: { x: 50, y: 50 } },
|
||||
{ percentage: 100, properties: { x: 100, y: 0 } },
|
||||
],
|
||||
},
|
||||
arcPath: {
|
||||
enabled: true,
|
||||
autoRotate: false,
|
||||
segments: [{ curviness: 1 }, { curviness: 1 }],
|
||||
},
|
||||
});
|
||||
|
||||
function arcFixture(x: number, y: number) {
|
||||
const commitMutation = vi.fn(async () => undefined);
|
||||
const session = { commitMutation } as unknown as EnableKeyframesSession;
|
||||
const sel = {
|
||||
id: "el",
|
||||
selector: "#el",
|
||||
element: { isConnected: true } as HTMLElement,
|
||||
dataAttributes: { duration: "10" },
|
||||
} as DomEditSelection;
|
||||
const iframe = {
|
||||
contentWindow: {
|
||||
gsap: { getProperty: (_element: Element, property: string) => (property === "x" ? x : y) },
|
||||
},
|
||||
} as unknown as HTMLIFrameElement;
|
||||
return { commitMutation, iframe, sel, session };
|
||||
}
|
||||
|
||||
it("removes an existing interior stop without redistributing the remaining times", async () => {
|
||||
const fixture = arcFixture(50, 50);
|
||||
await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 5, fixture.iframe);
|
||||
expect(fixture.commitMutation).toHaveBeenCalledWith(
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: arcAnim.id,
|
||||
targetSelector: "#el",
|
||||
position: 0,
|
||||
duration: 10,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||
{ percentage: 100, properties: { x: 100, y: 0 } },
|
||||
],
|
||||
ease: "none",
|
||||
},
|
||||
{ label: "Remove keyframe", softReload: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the path endpoints", async () => {
|
||||
const fixture = arcFixture(0, 0);
|
||||
await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 0, fixture.iframe);
|
||||
expect(fixture.commitMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds a temporal keyframe at the exact playhead while preserving authored times", async () => {
|
||||
const fixture = arcFixture(25, 25);
|
||||
await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 2.5, fixture.iframe);
|
||||
expect(fixture.commitMutation).toHaveBeenCalledWith(
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: arcAnim.id,
|
||||
targetSelector: "#el",
|
||||
position: 0,
|
||||
duration: 10,
|
||||
keyframes: [
|
||||
{ percentage: 0, properties: { x: 0, y: 0 } },
|
||||
{ percentage: 25, properties: { x: 25, y: 25 } },
|
||||
{ percentage: 50, properties: { x: 50, y: 50 } },
|
||||
{ percentage: 100, properties: { x: 100, y: 0 } },
|
||||
],
|
||||
ease: "none",
|
||||
},
|
||||
{ label: "Add keyframe", softReload: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the owning clip duration when an arc omits its outer duration", async () => {
|
||||
const fixture = arcFixture(25, 25);
|
||||
const durationlessArc = { ...arcAnim, duration: undefined };
|
||||
|
||||
await applyArcKeyframeAtPlayhead(
|
||||
fixture.session,
|
||||
fixture.sel,
|
||||
durationlessArc,
|
||||
2.5,
|
||||
fixture.iframe,
|
||||
);
|
||||
|
||||
expect(fixture.commitMutation).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "replace-with-keyframes",
|
||||
duration: 10,
|
||||
keyframes: expect.arrayContaining([{ percentage: 25, properties: { x: 25, y: 25 } }]),
|
||||
}),
|
||||
{ label: "Add keyframe", softReload: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function renderEnableKeyframes(session: EnableKeyframesSession): () => Promise<void> {
|
||||
let enable: (() => Promise<void>) | null = null;
|
||||
function Probe() {
|
||||
|
||||
@@ -12,16 +12,22 @@ import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gs
|
||||
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
||||
import { usePlayerStore } from "../player/store/playerStore";
|
||||
import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache";
|
||||
import { selectorFromSelection, computeElementPercentage, isInstantHold } from "./gsapShared";
|
||||
import {
|
||||
selectorFromSelection,
|
||||
computeElementPercentage,
|
||||
isInstantHold,
|
||||
resolveEditableTweenDuration,
|
||||
} from "./gsapShared";
|
||||
import {
|
||||
absoluteToPercentage,
|
||||
resolveTweenStart,
|
||||
resolveTweenDuration,
|
||||
isTimeWithinTween,
|
||||
} from "../utils/globalTimeCompiler";
|
||||
import { POSITION_PROPS } from "./gsapRuntimeReaders";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
import { nearestPointOnPath } from "../components/editor/motionPathGeometry";
|
||||
import type { CommitMutationOptions } from "./gsapScriptCommitTypes";
|
||||
import { buildTemporalArcKeyframes } from "./gsapDragPositionCommit";
|
||||
|
||||
let enableKeyframesTransactionCounter = 0;
|
||||
|
||||
@@ -89,9 +95,10 @@ export function buildExtendedKeyframes(
|
||||
anim: GsapAnimation,
|
||||
currentTime: number,
|
||||
position: Record<string, number>,
|
||||
sourceDuration = resolveTweenDuration(anim),
|
||||
): { position: number; duration: number; keyframes: GsapPercentageKeyframe[] } {
|
||||
const oldStart = resolveTweenStart(anim) ?? 0;
|
||||
const oldDuration = resolveTweenDuration(anim);
|
||||
const oldDuration = sourceDuration;
|
||||
const newStart = Math.min(oldStart, currentTime);
|
||||
const newEnd = Math.max(oldStart + oldDuration, currentTime);
|
||||
const newDuration = roundTo3(newEnd - newStart);
|
||||
@@ -222,6 +229,37 @@ async function fetchAnimationsForElement(sel: DomEditSelection): Promise<GsapAni
|
||||
return (await tryFetchAnimationsForElement(sel)) ?? [];
|
||||
}
|
||||
|
||||
async function extendKeyframedTweenToPlayhead(
|
||||
session: EnableKeyframesSession,
|
||||
sel: DomEditSelection,
|
||||
anim: GsapAnimation,
|
||||
currentTime: number,
|
||||
duration: number,
|
||||
iframe: HTMLIFrameElement | null,
|
||||
commitOverrides?: Partial<CommitMutationOptions>,
|
||||
): Promise<void> {
|
||||
const selector = selectorFromSelection(sel);
|
||||
const position = readElementPosition(iframe, sel, anim);
|
||||
if (!selector || Object.keys(position).length === 0 || !session.commitMutation) return;
|
||||
const extended = buildExtendedKeyframes(anim, currentTime, position, duration);
|
||||
await session.commitMutation(
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: anim.id,
|
||||
targetSelector: selector,
|
||||
position: extended.position,
|
||||
duration: extended.duration,
|
||||
keyframes: extended.keyframes,
|
||||
ease: anim.ease,
|
||||
},
|
||||
{
|
||||
label: "Add keyframe",
|
||||
softReload: true,
|
||||
...commitOverrides,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply "add keyframe at playhead" to a tween that already has x/y keyframes:
|
||||
* toggle off an existing stop, add one at the playhead's tween-relative %, or —
|
||||
@@ -237,31 +275,22 @@ async function applyKeyframeAtPlayhead(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
commitOverrides?: Partial<CommitMutationOptions>,
|
||||
): Promise<void> {
|
||||
if (!isPlayheadWithinTween(kfAnim, t)) {
|
||||
const position = readElementPosition(iframe, sel, kfAnim);
|
||||
const selector = selectorFromSelection(sel);
|
||||
if (selector && Object.keys(position).length > 0 && session.commitMutation) {
|
||||
const extended = buildExtendedKeyframes(kfAnim, t, position);
|
||||
await session.commitMutation(
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: kfAnim.id,
|
||||
targetSelector: selector,
|
||||
position: extended.position,
|
||||
duration: extended.duration,
|
||||
keyframes: extended.keyframes,
|
||||
ease: kfAnim.ease,
|
||||
},
|
||||
{
|
||||
label: "Add keyframe",
|
||||
softReload: true,
|
||||
...commitOverrides,
|
||||
},
|
||||
);
|
||||
}
|
||||
const duration = resolveEditableTweenDuration(kfAnim, sel);
|
||||
const start = resolveTweenStart(kfAnim);
|
||||
if (start !== null && !isTimeWithinTween(t, start, duration)) {
|
||||
await extendKeyframedTweenToPlayhead(
|
||||
session,
|
||||
sel,
|
||||
kfAnim,
|
||||
t,
|
||||
duration,
|
||||
iframe,
|
||||
commitOverrides,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const pct = computeElementPercentage(t, sel, kfAnim);
|
||||
const pct =
|
||||
start === null ? computeElementPercentage(t, sel) : absoluteToPercentage(t, start, duration);
|
||||
const existing = kfAnim.keyframes?.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1);
|
||||
if (existing) {
|
||||
session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage);
|
||||
@@ -332,14 +361,13 @@ export async function promoteSetToKeyframes(
|
||||
}
|
||||
|
||||
/**
|
||||
* An arc (motionPath) tween — its waypoints are reconstructed onto `keyframes`, so
|
||||
* it must be edited as waypoints (not x/y keyframes, which would break the curve).
|
||||
* "Add keyframe at playhead" drops a waypoint where the element currently sits on
|
||||
* the path, inserted at the matching segment so the curve is preserved. Outside the
|
||||
* range, extend the duration so the motion reaches the playhead.
|
||||
* Convert an arc (motionPath) tween to temporal x/y keyframes before toggling the
|
||||
* playhead stop. A toolbar command named "Add keyframe at playhead" must preserve
|
||||
* every authored stop's time; inserting a spatial waypoint instead redistributes
|
||||
* the path and can silently compress the animation.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
async function applyArcWaypointAtPlayhead(
|
||||
export async function applyArcKeyframeAtPlayhead(
|
||||
session: EnableKeyframesSession,
|
||||
sel: DomEditSelection,
|
||||
arcAnim: GsapAnimation,
|
||||
@@ -347,8 +375,11 @@ async function applyArcWaypointAtPlayhead(
|
||||
iframe: HTMLIFrameElement | null,
|
||||
): Promise<void> {
|
||||
if (!session.commitMutation) return;
|
||||
if (!isPlayheadWithinTween(arcAnim, t)) {
|
||||
const start = resolveTweenStart(arcAnim) ?? 0;
|
||||
const targetSelector = selectorFromSelection(sel);
|
||||
if (!targetSelector) return;
|
||||
const start = resolveTweenStart(arcAnim) ?? 0;
|
||||
const duration = resolveEditableTweenDuration(arcAnim, sel);
|
||||
if (!isTimeWithinTween(t, start, duration)) {
|
||||
if (t > start) {
|
||||
await session.commitMutation(
|
||||
{
|
||||
@@ -361,30 +392,45 @@ async function applyArcWaypointAtPlayhead(
|
||||
}
|
||||
return;
|
||||
}
|
||||
const nodes = arcAnim.keyframes?.keyframes ?? [];
|
||||
const playheadPercentage = absoluteToPercentage(t, start, duration);
|
||||
const timedNodeIndex = nodes.findIndex(
|
||||
(node) => Math.abs(node.percentage - playheadPercentage) <= 1,
|
||||
);
|
||||
if (timedNodeIndex !== -1) {
|
||||
if (timedNodeIndex > 0 && timedNodeIndex < nodes.length - 1) {
|
||||
await session.commitMutation(
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: arcAnim.id,
|
||||
targetSelector,
|
||||
position: roundTo3(start),
|
||||
duration: roundTo3(duration),
|
||||
keyframes: nodes.filter((_, index) => index !== timedNodeIndex),
|
||||
ease: "none",
|
||||
},
|
||||
{ label: "Remove keyframe", softReload: true },
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const live = readElementPosition(iframe, sel, arcAnim);
|
||||
if (typeof live.x !== "number" || typeof live.y !== "number") return;
|
||||
const liveX = live.x;
|
||||
const liveY = live.y;
|
||||
const nodes = (arcAnim.keyframes?.keyframes ?? [])
|
||||
.map((k) => ({ x: k.properties.x, y: k.properties.y }))
|
||||
.filter(
|
||||
(p): p is { x: number; y: number } => typeof p.x === "number" && typeof p.y === "number",
|
||||
);
|
||||
// Don't duplicate a waypoint that already sits where the element is (e.g. at the
|
||||
// path endpoints).
|
||||
const WAYPOINT_MERGE_PX = 6;
|
||||
if (nodes.some((n) => Math.hypot(n.x - liveX, n.y - liveY) <= WAYPOINT_MERGE_PX)) return;
|
||||
const proj = nearestPointOnPath(liveX, liveY, nodes);
|
||||
if (!proj) return;
|
||||
await session.commitMutation(
|
||||
{
|
||||
type: "add-motion-path-point",
|
||||
type: "replace-with-keyframes",
|
||||
animationId: arcAnim.id,
|
||||
index: proj.segIndex + 1,
|
||||
x: liveX,
|
||||
y: liveY,
|
||||
targetSelector,
|
||||
position: roundTo3(start),
|
||||
duration: roundTo3(duration),
|
||||
keyframes: buildTemporalArcKeyframes(arcAnim, playheadPercentage, {
|
||||
x: live.x,
|
||||
y: live.y,
|
||||
}),
|
||||
ease: "none",
|
||||
},
|
||||
{ label: "Add waypoint", softReload: true },
|
||||
{ label: "Add keyframe", softReload: true },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -420,7 +466,7 @@ export function useEnableKeyframes(
|
||||
const flatAnim = anims.find((a) => !a.keyframes && !a.arcPath && !isInstantHold(a));
|
||||
|
||||
if (arcAnim) {
|
||||
await applyArcWaypointAtPlayhead(session, sel, arcAnim, t, iframe);
|
||||
await applyArcKeyframeAtPlayhead(session, sel, arcAnim, t, iframe);
|
||||
} else if (kfAnim) {
|
||||
await applyKeyframeAtPlayhead(session, sel, kfAnim, t, iframe);
|
||||
} else if (setAnim) {
|
||||
|
||||
@@ -20,7 +20,12 @@ afterEach(() => {
|
||||
const selection: DomEditSelection = { id: "box", selector: "#box" } as DomEditSelection;
|
||||
|
||||
function successfulCommitMutation() {
|
||||
return vi.fn<(...args: unknown[]) => Promise<unknown>>(async () => ({ ok: true }));
|
||||
return vi.fn<(...args: unknown[]) => Promise<unknown>>(async (...args) => {
|
||||
const options = args[2] as {
|
||||
onResult?: (result: { ok: boolean; changed: boolean }) => void;
|
||||
};
|
||||
options.onResult?.({ ok: true, changed: true });
|
||||
});
|
||||
}
|
||||
|
||||
function renderKeyframeOps(over: {
|
||||
@@ -54,6 +59,18 @@ function renderKeyframeOps(over: {
|
||||
return captured.api;
|
||||
}
|
||||
|
||||
async function moveKeyframeWith(
|
||||
commitMutation: (...args: unknown[]) => Promise<unknown>,
|
||||
): Promise<{ committed: boolean; trackGsapSaveFailure: ReturnType<typeof vi.fn> }> {
|
||||
const trackGsapSaveFailure = vi.fn();
|
||||
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
|
||||
let committed = true;
|
||||
await act(async () => {
|
||||
committed = await api.moveKeyframe(selection, "box-to-0-position", 50, 75);
|
||||
});
|
||||
return { committed, trackGsapSaveFailure };
|
||||
}
|
||||
|
||||
describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
||||
it("issues a resize-keyframed-tween mutation with the remap + window", async () => {
|
||||
const commitMutation = successfulCommitMutation();
|
||||
@@ -64,8 +81,9 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
||||
{ from: 0, to: 0 },
|
||||
{ from: 100, to: 100 },
|
||||
];
|
||||
let committed = false;
|
||||
await act(async () => {
|
||||
api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, pctRemap);
|
||||
committed = await api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, pctRemap);
|
||||
});
|
||||
|
||||
expect(commitMutation).toHaveBeenCalledTimes(1);
|
||||
@@ -79,6 +97,7 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
||||
pctRemap,
|
||||
});
|
||||
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
|
||||
expect(committed).toBe(true);
|
||||
});
|
||||
|
||||
it("routes a rejected commit to trackGsapSaveFailure (no unhandled rejection)", async () => {
|
||||
@@ -89,10 +108,11 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
||||
const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>();
|
||||
const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure });
|
||||
|
||||
let committed = true;
|
||||
await act(async () => {
|
||||
api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, [{ from: 100, to: 100 }]);
|
||||
// let the rejected commit promise settle inside act
|
||||
await Promise.resolve();
|
||||
committed = await api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, [
|
||||
{ from: 100, to: 100 },
|
||||
]);
|
||||
});
|
||||
|
||||
expect(trackGsapSaveFailure).toHaveBeenCalledTimes(1);
|
||||
@@ -101,6 +121,46 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => {
|
||||
expect(selArg).toBe(selection);
|
||||
expect((mutationArg as { type: string }).type).toBe("resize-keyframed-tween");
|
||||
expect(labelArg).toBe("Retime keyframe (resize tween)");
|
||||
expect(committed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGsapKeyframeOps — moveKeyframe settlement", () => {
|
||||
it("returns false when the commit settles without a durable writer result", async () => {
|
||||
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(vi.fn(async () => {}));
|
||||
|
||||
expect(committed).toBe(false);
|
||||
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false when the writer accepts but does not change the keyframe", async () => {
|
||||
const commitMutation = vi.fn(async (...args: unknown[]) => {
|
||||
const options = args[2] as { onResult?: (result: { ok: boolean; changed: boolean }) => void };
|
||||
options.onResult?.({ ok: true, changed: false });
|
||||
});
|
||||
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation);
|
||||
|
||||
expect(committed).toBe(false);
|
||||
expect(trackGsapSaveFailure).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false and tracks a rejected move", async () => {
|
||||
const error = new Error("write failed");
|
||||
const commitMutation = vi.fn().mockRejectedValue(error);
|
||||
const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation);
|
||||
|
||||
expect(committed).toBe(false);
|
||||
expect(trackGsapSaveFailure).toHaveBeenCalledExactlyOnceWith(
|
||||
error,
|
||||
selection,
|
||||
{
|
||||
type: "move-keyframe",
|
||||
animationId: "box-to-0-position",
|
||||
fromPercentage: 50,
|
||||
toPercentage: 75,
|
||||
},
|
||||
"Move keyframe to 75%",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ export function useGsapKeyframeOps({
|
||||
);
|
||||
|
||||
const moveKeyframe = useCallback(
|
||||
(
|
||||
async (
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
fromPercentage: number,
|
||||
@@ -247,18 +247,26 @@ export function useGsapKeyframeOps({
|
||||
// updateKeyframeCacheFromParsed re-keys the diamond from the fresh parse, so no
|
||||
// optimistic cache write is needed (mapping the tween-% to clip-% here would
|
||||
// duplicate that math). softReload mirrors remove-keyframe.
|
||||
void commitMutation(selection, mutation, {
|
||||
label: `Move keyframe to ${toPercentage}%`,
|
||||
softReload: true,
|
||||
}).catch((error) => {
|
||||
try {
|
||||
let changed = false;
|
||||
await commitMutation(selection, mutation, {
|
||||
label: `Move keyframe to ${toPercentage}%`,
|
||||
softReload: true,
|
||||
onResult: (result) => {
|
||||
changed = result.changed !== false;
|
||||
},
|
||||
});
|
||||
return changed;
|
||||
} catch (error) {
|
||||
trackGsapSaveFailure(error, selection, mutation, `Move keyframe to ${toPercentage}%`);
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[commitMutation, trackGsapSaveFailure],
|
||||
);
|
||||
|
||||
const resizeKeyframedTween = useCallback(
|
||||
(
|
||||
async (
|
||||
selection: DomEditSelection,
|
||||
animationId: string,
|
||||
position: number,
|
||||
@@ -275,12 +283,20 @@ export function useGsapKeyframeOps({
|
||||
// Boundary drag-to-retime: the server re-keys keyframes in place + grows the
|
||||
// tween window, preserving _auto / per-keyframe ease / easeEach / outer ease.
|
||||
// softReload re-keys the diamonds from the fresh parse (mirrors moveKeyframe).
|
||||
void commitMutation(selection, mutation, {
|
||||
label: "Retime keyframe (resize tween)",
|
||||
softReload: true,
|
||||
}).catch((error) => {
|
||||
try {
|
||||
let changed = false;
|
||||
await commitMutation(selection, mutation, {
|
||||
label: "Retime keyframe (resize tween)",
|
||||
softReload: true,
|
||||
onResult: (result) => {
|
||||
changed = result.changed !== false;
|
||||
},
|
||||
});
|
||||
return changed;
|
||||
} catch (error) {
|
||||
trackGsapSaveFailure(error, selection, mutation, "Retime keyframe (resize tween)");
|
||||
});
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[commitMutation, trackGsapSaveFailure],
|
||||
);
|
||||
|
||||
@@ -298,6 +298,27 @@ describe("runCommit — instantPatch wiring", () => {
|
||||
expect(deps.showToast).toHaveBeenCalledWith("A keyframe already exists at that time", "info");
|
||||
});
|
||||
|
||||
it("publishes the server mutation outcome to callers", async () => {
|
||||
mockFetchResult({ changed: false });
|
||||
const deps = renderCommitHook();
|
||||
let commitResult: MutationResult | undefined;
|
||||
|
||||
await act(async () => {
|
||||
await deps.api.commitMutation(
|
||||
selection,
|
||||
{ type: "move-keyframe", fromPercentage: 50, toPercentage: 75 },
|
||||
{
|
||||
label: "Move keyframe",
|
||||
onResult: (result) => {
|
||||
commitResult = result;
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
expect(commitResult).toEqual(expect.objectContaining({ ok: true, changed: false }));
|
||||
});
|
||||
|
||||
it("no-op commit with an instantPatch still patches the runtime (paired x/y commits)", async () => {
|
||||
patchRuntimeTweenInPlace.mockReturnValue(true);
|
||||
mockFetchResult({ changed: false });
|
||||
|
||||
@@ -338,6 +338,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
mutateGsapScript(pid, targetPath, mutation),
|
||||
);
|
||||
if (!result) return;
|
||||
options.onResult?.(result);
|
||||
await finalizeSuccessfulMutation(pid, compositionPath, selection, mutation, targetPath, result, options);
|
||||
}, [showToast, finalizeSuccessfulMutation]);
|
||||
|
||||
@@ -350,6 +351,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra
|
||||
mutateGsapScriptBatch(pid, targetPath, mutations),
|
||||
);
|
||||
if (!result) return;
|
||||
options.onResult?.(result);
|
||||
await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, options);
|
||||
}, [showToast, finalizeSuccessfulMutation]);
|
||||
|
||||
|
||||
@@ -38,8 +38,8 @@ function makeParams(overrides: Partial<Params> = {}): Params {
|
||||
addKeyframe: vi.fn(),
|
||||
addKeyframeBatch: resolved(),
|
||||
removeKeyframe: vi.fn(),
|
||||
moveKeyframe: vi.fn(),
|
||||
resizeKeyframedTween: vi.fn(),
|
||||
moveKeyframe: vi.fn().mockResolvedValue(true),
|
||||
resizeKeyframedTween: vi.fn().mockResolvedValue(true),
|
||||
convertToKeyframes: resolved(),
|
||||
removeAllKeyframes: resolved(),
|
||||
handleDomManualEditsReset: vi.fn(),
|
||||
@@ -144,3 +144,22 @@ describe("useGsapSelectionHandlers selection override", () => {
|
||||
rendered.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useGsapSelectionHandlers retime settlement", () => {
|
||||
it("returns false without a selection and forwards the mutation result with one", async () => {
|
||||
const moveKeyframe = vi.fn().mockResolvedValue(true);
|
||||
const withoutSelection = renderHandlers(makeParams({ domEditSelection: null, moveKeyframe }));
|
||||
await expect(
|
||||
withoutSelection.handlers().handleGsapMoveKeyframe("anim-1", 50, 75),
|
||||
).resolves.toBe(false);
|
||||
expect(moveKeyframe).not.toHaveBeenCalled();
|
||||
withoutSelection.unmount();
|
||||
|
||||
const withSelection = renderHandlers(makeParams({ moveKeyframe }));
|
||||
await expect(withSelection.handlers().handleGsapMoveKeyframe("anim-1", 50, 75)).resolves.toBe(
|
||||
true,
|
||||
);
|
||||
expect(moveKeyframe).toHaveBeenCalledOnce();
|
||||
withSelection.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,14 +94,14 @@ export function useGsapSelectionHandlers({
|
||||
animId: string,
|
||||
fromPercentage: number,
|
||||
toPercentage: number,
|
||||
) => void;
|
||||
) => Promise<boolean>;
|
||||
resizeKeyframedTween: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
position: number,
|
||||
duration: number,
|
||||
pctRemap: Array<{ from: number; to: number }>,
|
||||
) => void;
|
||||
) => Promise<boolean>;
|
||||
convertToKeyframes: (
|
||||
sel: DomEditSelection,
|
||||
animId: string,
|
||||
@@ -355,7 +355,7 @@ export function useGsapSelectionHandlers({
|
||||
const anim = animationOverride ?? selectedGsapAnimations.find((a) => a.id === animId);
|
||||
const toPercentage = computeCurrentPercentage(sel, anim);
|
||||
trackStudioEvent("keyframe", { action: "move_to_playhead" });
|
||||
moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
||||
void moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
||||
},
|
||||
[resolveWriteSelection, selectedGsapAnimations, moveKeyframe],
|
||||
);
|
||||
@@ -368,13 +368,13 @@ export function useGsapSelectionHandlers({
|
||||
selectionOverride?: DomEditSelection | null,
|
||||
) => {
|
||||
const sel = resolveWriteSelection(selectionOverride);
|
||||
if (!sel) return;
|
||||
if (!sel) return Promise.resolve(false);
|
||||
// Atomic retime: preserves the keyframe's value + per-keyframe ease. Both
|
||||
// percentages are tween-relative (the drag handler converts the drop
|
||||
// position before calling). No optimistic runtime hold — the soft-reload
|
||||
// re-keys the diamond from source.
|
||||
trackStudioEvent("keyframe", { action: "retime" });
|
||||
moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
||||
return moveKeyframe(sel, animId, fromPercentage, toPercentage);
|
||||
},
|
||||
[resolveWriteSelection, moveKeyframe],
|
||||
);
|
||||
@@ -388,11 +388,11 @@ export function useGsapSelectionHandlers({
|
||||
selectionOverride?: DomEditSelection | null,
|
||||
) => {
|
||||
const sel = resolveWriteSelection(selectionOverride);
|
||||
if (!sel) return;
|
||||
if (!sel) return Promise.resolve(false);
|
||||
// Boundary drag-to-retime: grows/shifts the tween window + re-keys keyframes
|
||||
// in place. Distinct telemetry action so resize is separable from in-window move.
|
||||
trackStudioEvent("keyframe", { action: "retime_resize" });
|
||||
resizeKeyframedTween(sel, animId, position, duration, pctRemap);
|
||||
return resizeKeyframedTween(sel, animId, position, duration, pctRemap);
|
||||
},
|
||||
[resolveWriteSelection, resizeKeyframedTween],
|
||||
);
|
||||
@@ -417,11 +417,12 @@ export function useGsapSelectionHandlers({
|
||||
);
|
||||
|
||||
const handleGsapRemoveAllKeyframes = useCallback(
|
||||
(animId: string) => {
|
||||
if (!domEditSelection) return;
|
||||
(animId: string, selectionOverride?: DomEditSelection | null) => {
|
||||
const selection = selectionOverride ?? domEditSelection ?? lastSelectionRef.current;
|
||||
if (!selection) return;
|
||||
observeGsapMutation(
|
||||
removeAllKeyframes(domEditSelection, animId),
|
||||
domEditSelection,
|
||||
removeAllKeyframes(selection, animId),
|
||||
selection,
|
||||
"remove-all-keyframes",
|
||||
"Remove all keyframes",
|
||||
);
|
||||
|
||||
@@ -10,6 +10,16 @@ interface StudioTestHookDeps {
|
||||
) => void;
|
||||
}
|
||||
|
||||
interface StudioTestApi {
|
||||
selectByDomId: (id: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__studioTest?: StudioTestApi;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dev-only headless-QA shortcut. Selecting an element normally requires a
|
||||
* pixel-precise click inside the preview iframe, which automated verification
|
||||
@@ -33,7 +43,7 @@ export function useStudioTestHooks({
|
||||
isDev = false;
|
||||
}
|
||||
if (!isDev || typeof window === "undefined") return;
|
||||
const api = {
|
||||
const api: StudioTestApi = {
|
||||
selectByDomId: async (id: string): Promise<boolean> => {
|
||||
const element = previewIframeRef.current?.contentDocument?.getElementById(id) ?? null;
|
||||
if (!element) return false;
|
||||
@@ -43,11 +53,11 @@ export function useStudioTestHooks({
|
||||
return true;
|
||||
},
|
||||
};
|
||||
(window as unknown as { __studioTest?: typeof api }).__studioTest = api;
|
||||
window.__studioTest = api;
|
||||
return () => {
|
||||
// delete, not `= undefined`: an own key holding undefined keeps
|
||||
// `"__studioTest" in window` true, which defeats feature detection.
|
||||
delete (window as unknown as { __studioTest?: typeof api }).__studioTest;
|
||||
delete window.__studioTest;
|
||||
};
|
||||
}, [applyDomSelection, buildDomSelectionFromTarget, previewIframeRef]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user