Files
hyperframes/packages/studio/src/hooks/useGsapKeyframeOps.ts
T
Miguel Angel Simon Sierra b8ff8bf0f3 fix(studio): close the review findings that survived the stack
Selector reads now go through one inverse of `idSelector`. Every writer emits
`[id="01-hook-hero"]` for an id a `#id` selector can't address, but the readers
still matched `#id` only, so the post-commit keyframe-cache refresh, the AST
load and the remove-all-keyframes clear all silently skipped exactly the ids
`idSelector` was added to support.

A keyframe merged from two tweens with different eases kept whichever ease
iterated last. Readers that don't check `easeAmbiguous` showed a curve from a
different animation than an edit would target, so the ambiguous flag now clears
`ease` instead of leaving an arbitrary one behind.

One tolerance for "the playhead is on this keyframe". The motion-path drag used
0.05% while the toolbar and the playhead apply used 1%, so a drag that landed a
fraction of a percent off an authored waypoint skipped the update-point branch
and appended a near-duplicate. `buildTemporalArcKeyframes` now owns the
invariant and replaces any keyframe inside the tolerance, rather than trusting
each caller's own pre-check.

The pending-retime bookkeeping matches on keyframe identity, not just on
"something is near that percentage" — an evenly spaced row cleared the entry off
an unrelated sibling. The neighbour clamp composes pending destinations in
before sorting, so a second drag can't cross a neighbour that already moved.

Also: `keyframeCache`/`gsapAnimations` setters return the same state for a write
that changes nothing (every no-op re-rendered every subscriber), the auto-expand
set drops clips that left the source so an undo/paste under the same id expands
again, `invalidateGsapCache` has a stable identity instead of re-creating the
whole timeline edit context each render, the studio test hook deletes its window
key rather than leaving it enumerable as undefined, and the past-last-row
extrapolation documents why it uses TRACK_H where the pre-first-row branch uses
row 0's own height.

Covers `idFromSelector` round-trips, the insert boundary band across plain,
expanded and unusable row heights, and the collapsed selection key for a
colon-bearing element id.
2026-07-28 00:40:46 +02:00

384 lines
13 KiB
TypeScript

// fallow-ignore-file code-duplication
// Add/remove operation-family transaction shapes stay parallel until SDK graduation.
import { useCallback } from "react";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { Composition } from "@hyperframes/sdk";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import { executeOptimistic } from "../utils/optimisticUpdate";
import {
sdkGsapKeyframePersist,
sdkGsapRemoveKeyframePersist,
sdkGsapRemoveAllKeyframesPersist,
sdkGsapConvertToKeyframesPersist,
cutoverCommittedOrThrow,
type CutoverDeps,
} from "../utils/sdkCutover";
import type { KeyframeCacheEntry } from "../player/store/playerStore";
import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit";
import { idFromSelector } from "./gsapShared";
import {
clearKeyframeCacheForElement,
readKeyframeSnapshot,
writeKeyframeCache,
} from "./gsapKeyframeCacheHelpers";
import type {
CommitMutation,
CommitMutationOptions,
SafeGsapCommitMutation,
TrackGsapSaveFailure,
} from "./gsapScriptCommitTypes";
function executeOptimisticKeyframeCacheUpdate(options: {
sourceFile: string;
elementId: string | null | undefined;
apply: (entry: KeyframeCacheEntry) => KeyframeCacheEntry;
persist: () => Promise<void>;
}): Promise<void> {
return executeOptimistic<KeyframeCacheEntry | undefined>({
apply: () => {
const prev = readKeyframeSnapshot(options.sourceFile, options.elementId);
if (prev) writeKeyframeCache(options.sourceFile, options.elementId, options.apply(prev));
return prev;
},
persist: options.persist,
rollback: (prev) => {
writeKeyframeCache(options.sourceFile, options.elementId, prev);
},
});
}
interface SdkKeyframeDeps {
sdkSession?: Composition | null;
sdkDeps?: CutoverDeps | null;
}
interface GsapKeyframeOpsParams extends SdkKeyframeDeps {
activeCompPath: string | null;
commitMutation: CommitMutation;
commitMutationSafely: SafeGsapCommitMutation;
trackGsapSaveFailure: TrackGsapSaveFailure;
}
/**
* Translate a gesture's commit overrides into the SDK persist options. The
* server path's `softReload`/`skipReload` maps to the SDK's `skipRefresh`, and
* `coalesceKey`/`coalesceMs` must ride along so an SDK-routed edit folds into
* one undo entry the same way the server path does.
*/
function toSdkPersistOptions(label: string, overrides?: Partial<CommitMutationOptions>) {
return {
label,
coalesceKey: overrides?.coalesceKey,
coalesceMs: overrides?.coalesceMs,
skipRefresh: overrides?.skipReload,
};
}
export function useGsapKeyframeOps({
activeCompPath,
commitMutation,
commitMutationSafely,
trackGsapSaveFailure,
sdkSession,
sdkDeps,
}: GsapKeyframeOpsParams) {
const addKeyframe = useCallback(
(
selection: DomEditSelection,
animationId: string,
percentage: number,
property: string,
value: number | string,
) => {
const sourceFile = selection.sourceFile || activeCompPath || "index.html";
const mutation = {
type: "add-keyframe",
animationId,
percentage,
properties: { [property]: value },
};
void executeOptimisticKeyframeCacheUpdate({
sourceFile,
elementId: selection.id,
// Merge into an existing keyframe at this percentage rather than
// appending a duplicate — matches addKeyframeToScript, which writes one
// keyframe per percentage (merging properties).
apply: (prev) => {
// Match addKeyframeToScript's merge tolerance (PCT_TOLERANCE = 2 in
// gsapWriterAcorn): a keyframe added within 2% of an existing one
// merges on disk, so the optimistic cache must merge it too — else the
// UI shows a phantom keyframe that vanishes on the next reload.
const idx = prev.keyframes.findIndex(
(kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) <= 2,
);
if (idx >= 0) {
const keyframes = prev.keyframes.slice();
keyframes[idx] = {
...keyframes[idx],
properties: { ...keyframes[idx].properties, [property]: value },
};
return { ...prev, keyframes };
}
return {
...prev,
keyframes: [...prev.keyframes, { percentage, properties: { [property]: value } }].sort(
(a, b) => a.percentage - b.percentage,
),
};
},
persist: async () => {
if (sdkSession && sdkDeps) {
const handled = await sdkGsapKeyframePersist(
sourceFile,
animationId,
percentage,
{ [property]: value },
sdkSession,
sdkDeps,
{
label: `Add keyframe at ${percentage}%`,
coalesceKey: `gsap:${animationId}:kf:${percentage}`,
},
);
if (cutoverCommittedOrThrow(handled)) return;
}
await commitMutation(selection, mutation, {
label: `Add keyframe at ${percentage}%`,
softReload: true,
});
},
}).catch((error) => {
trackGsapSaveFailure(error, selection, mutation, `Add keyframe at ${percentage}%`);
});
},
[activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps],
);
const addKeyframeBatch = useCallback(
async (
selection: DomEditSelection,
animationId: string,
percentage: number,
properties: Record<string, number | string>,
commitOverrides?: Partial<CommitMutationOptions>,
) => {
if (sdkSession && sdkDeps) {
const sourceFile = selection.sourceFile || activeCompPath || "index.html";
const handled = await sdkGsapKeyframePersist(
sourceFile,
animationId,
percentage,
properties,
sdkSession,
sdkDeps,
toSdkPersistOptions(`Add keyframe at ${percentage}%`, commitOverrides),
);
if (cutoverCommittedOrThrow(handled)) return;
}
return commitMutation(
selection,
{ type: "add-keyframe", animationId, percentage, properties },
{
label: `Add keyframe at ${percentage}%`,
softReload: true,
...commitOverrides,
},
);
},
[commitMutation, activeCompPath, sdkSession, sdkDeps],
);
const removeKeyframe = useCallback(
(
selection: DomEditSelection,
animationId: string,
percentage: number,
commitOverrides?: Partial<CommitMutationOptions>,
) => {
const sourceFile = selection.sourceFile || activeCompPath || "index.html";
const mutation = { type: "remove-keyframe", animationId, percentage };
void executeOptimisticKeyframeCacheUpdate({
sourceFile,
elementId: selection.id,
apply: (prev) => ({
...prev,
// Match the writer's removal tolerance (PCT_TOLERANCE = 2 in
// gsapWriterAcorn): removing at e.g. 49% drops a keyframe at 50% on
// disk, so the optimistic cache must drop it too — else the stranded
// entry is a phantom that vanishes on the next reload (mirror of the
// add-path tolerance fix).
keyframes: prev.keyframes.filter(
(kf) => Math.abs((kf.tweenPercentage ?? kf.percentage) - percentage) > 2,
),
}),
persist: async () => {
const label = `Remove keyframe at ${percentage}%`;
if (sdkSession && sdkDeps) {
const handled = await sdkGsapRemoveKeyframePersist(
sourceFile,
animationId,
percentage,
sdkSession,
sdkDeps,
toSdkPersistOptions(label, commitOverrides),
);
if (cutoverCommittedOrThrow(handled)) return;
}
const commitOptions = commitOverrides?.skipReload
? { label, ...commitOverrides }
: { label, softReload: true, ...commitOverrides };
await commitMutation(selection, mutation, commitOptions);
},
}).catch((error) => {
trackGsapSaveFailure(error, selection, mutation, `Remove keyframe at ${percentage}%`);
});
},
[activeCompPath, commitMutation, trackGsapSaveFailure, sdkSession, sdkDeps],
);
const moveKeyframe = useCallback(
async (
selection: DomEditSelection,
animationId: string,
fromPercentage: number,
toPercentage: number,
) => {
const mutation = { type: "move-keyframe", animationId, fromPercentage, toPercentage };
// No SDK persist helper exists for retime — server path only. The post-commit
// 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.
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,
duration: number,
pctRemap: Array<{ from: number; to: number }>,
) => {
const mutation = {
type: "resize-keyframed-tween",
animationId,
position,
duration,
pctRemap,
};
// 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).
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],
);
const convertToKeyframes = useCallback(
async (
selection: DomEditSelection,
animationId: string,
resolvedFromValues?: Record<string, number | string>,
duration?: number,
commitOverrides: Partial<CommitMutationOptions> = { softReload: true },
) => {
if (sdkSession && sdkDeps) {
const targetPath = selection.sourceFile || activeCompPath || "index.html";
const handled = await sdkGsapConvertToKeyframesPersist(
targetPath,
animationId,
resolvedFromValues,
sdkSession,
sdkDeps,
toSdkPersistOptions("Convert to keyframes", commitOverrides),
);
if (cutoverCommittedOrThrow(handled)) return;
}
return commitMutation(
selection,
// `duration` only applies when the target is a static `set` (which has
// none) — it spans the converted keyframes across the element's clip.
{ type: "convert-to-keyframes", animationId, resolvedFromValues, duration },
{ label: "Convert to keyframes", ...commitOverrides },
);
},
[commitMutation, activeCompPath, sdkSession, sdkDeps],
);
const removeAllKeyframes = useCallback(
async (selection: DomEditSelection, animationId: string) => {
const targetPath = selection.sourceFile || activeCompPath || "index.html";
// remove-all-keyframes collapses the tween to a static hold and the commit
// path doesn't return parsed animations, so the keyframe cache is never
// refreshed — clear it here so the timeline diamonds disappear immediately.
const elementId = selection.id ?? idFromSelector(selection.selector);
if (elementId) clearKeyframeCacheForElement(targetPath, elementId);
if (sdkSession && sdkDeps) {
const handled = await sdkGsapRemoveAllKeyframesPersist(
targetPath,
animationId,
sdkSession,
sdkDeps,
{ label: "Remove all keyframes" },
);
if (cutoverCommittedOrThrow(handled)) return;
}
commitMutationSafely(
selection,
{ type: "remove-all-keyframes", animationId },
{ label: "Remove all keyframes", softReload: true },
);
},
[commitMutationSafely, activeCompPath, sdkSession, sdkDeps],
);
const commitKeyframeAtTime = useCallback(
(
selection: DomEditSelection,
absoluteTime: number,
animations: GsapAnimation[],
properties: Record<string, number | string>,
) => commitKeyframeAtTimeImpl(selection, absoluteTime, animations, properties, commitMutation),
[commitMutation],
);
return {
addKeyframe,
addKeyframeBatch,
removeKeyframe,
moveKeyframe,
resizeKeyframedTween,
convertToKeyframes,
removeAllKeyframes,
commitKeyframeAtTime,
};
}