mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
fix(studio): make inspector commits transactional (#2987)
* fix(studio): make inspector commits transactional * fix(studio): make inspector persistence atomic * fix(studio): preserve synchronous gesture semantics
This commit is contained in:
@@ -24,22 +24,16 @@ import {
|
||||
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
|
||||
import { assertGsapEditPersisted, directEditOutcomeForProperties } from "./gsapEditOutcome";
|
||||
import {
|
||||
assertGsapEditPersisted,
|
||||
directEditOutcomeForProperties,
|
||||
GsapEditBlockedError,
|
||||
} from "./gsapEditOutcome";
|
||||
import type { CommitMutation, CommitMutationCall } from "./gsapScriptCommitTypes";
|
||||
|
||||
interface CommitAnimatedPropertyDeps {
|
||||
selectedGsapAnimations: GsapAnimation[];
|
||||
gsapCommitMutation:
|
||||
| ((
|
||||
selection: DomEditSelection,
|
||||
mutation: Record<string, unknown>,
|
||||
options: {
|
||||
label: string;
|
||||
coalesceKey?: string;
|
||||
softReload?: boolean;
|
||||
skipReload?: boolean;
|
||||
},
|
||||
) => Promise<void>)
|
||||
| null;
|
||||
gsapCommitMutation: CommitMutation | null;
|
||||
addGsapAnimation: (
|
||||
selection: DomEditSelection,
|
||||
method: "to" | "from" | "set" | "fromTo",
|
||||
@@ -110,7 +104,7 @@ async function maybeAutoKeyframeSet(
|
||||
);
|
||||
}
|
||||
|
||||
type Commit = NonNullable<CommitAnimatedPropertyDeps["gsapCommitMutation"]>;
|
||||
type Commit = CommitMutation;
|
||||
|
||||
/** Undo-history label for a static-set commit, from the group it writes. */
|
||||
const STATIC_SET_LABELS: Partial<Record<ReturnType<typeof classifyPropertyGroup>, string>> = {
|
||||
@@ -140,6 +134,17 @@ async function commitSetProps(
|
||||
animations: GsapAnimation[],
|
||||
commit: Commit,
|
||||
): Promise<void> {
|
||||
const call = buildSetPropsCall(selection, setAnim, propEntries, selector);
|
||||
await commit(call.selection, call.mutation, call.options);
|
||||
await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
|
||||
}
|
||||
|
||||
function buildSetPropsCall(
|
||||
selection: DomEditSelection,
|
||||
setAnim: GsapAnimation,
|
||||
propEntries: [string, number | string][],
|
||||
selector: string | null,
|
||||
): CommitMutationCall {
|
||||
const properties = Object.fromEntries(propEntries);
|
||||
const numericProps: SetPatchProps = {};
|
||||
for (const [k, v] of propEntries) {
|
||||
@@ -155,16 +160,15 @@ async function commitSetProps(
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
await commit(
|
||||
return {
|
||||
selection,
|
||||
{ type: "update-properties", animationId: setAnim.id, properties },
|
||||
{
|
||||
mutation: { type: "update-properties", animationId: setAnim.id, properties },
|
||||
options: {
|
||||
label: staticSetLabel(propEntries),
|
||||
softReload: true,
|
||||
...(instantPatch ? { instantPatch } : {}),
|
||||
},
|
||||
);
|
||||
await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,7 +184,25 @@ async function commitStaticSet(
|
||||
animations: GsapAnimation[],
|
||||
commit: Commit,
|
||||
): Promise<void> {
|
||||
if (!selector) return;
|
||||
const calls = planStaticSetCalls(selection, propEntries, selector, animations);
|
||||
const only = calls[0];
|
||||
if (!only) return;
|
||||
if (calls.length === 1) {
|
||||
await commit(only.selection, only.mutation, only.options);
|
||||
return;
|
||||
}
|
||||
if (!commit.batch) {
|
||||
throw new Error("Atomic GSAP property batch is unavailable");
|
||||
}
|
||||
await commit.batch(calls, {
|
||||
label: staticSetLabel(propEntries),
|
||||
softReload: true,
|
||||
});
|
||||
}
|
||||
|
||||
function groupStaticSetEntries(
|
||||
propEntries: [string, number | string][],
|
||||
): Map<string, [string, number | string][]> {
|
||||
// One commit per PROPERTY GROUP, each into a static write that owns that group —
|
||||
// never a live tween, and never a foreign-group write (a width edit used to
|
||||
// merge into the element's position set, producing a mixed write the split
|
||||
@@ -194,9 +216,22 @@ async function commitStaticSet(
|
||||
batch.push(entry);
|
||||
byGroup.set(group, batch);
|
||||
}
|
||||
const staticWrites = animations.filter(
|
||||
(a) => isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element),
|
||||
);
|
||||
return byGroup;
|
||||
}
|
||||
|
||||
function planStaticSetCalls(
|
||||
selection: DomEditSelection,
|
||||
propEntries: [string, number | string][],
|
||||
selector: string | null,
|
||||
animations: GsapAnimation[],
|
||||
): CommitMutationCall[] {
|
||||
const byGroup = groupStaticSetEntries(propEntries);
|
||||
const staticWrites = selector
|
||||
? animations.filter(
|
||||
(a) =>
|
||||
isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element),
|
||||
)
|
||||
: [];
|
||||
// Resolve every group's target BEFORE committing anything, and coalesce
|
||||
// groups that land on the SAME write into one commit: the snapshot is captured
|
||||
// once, so if two groups resolved to one legacy mixed write, a first
|
||||
@@ -212,13 +247,12 @@ async function commitStaticSet(
|
||||
newSetBatches.push(batch);
|
||||
}
|
||||
}
|
||||
for (const [targetWrite, batch] of byTargetWrite) {
|
||||
await commitSetProps(selection, targetWrite, batch, selector, animations, commit);
|
||||
}
|
||||
// Fresh adds don't reshape existing sets, so their ids can't go stale.
|
||||
for (const batch of newSetBatches) {
|
||||
await addGlobalStaticSet(selection, batch, commit);
|
||||
}
|
||||
return [
|
||||
...[...byTargetWrite].map(([targetWrite, batch]) =>
|
||||
buildSetPropsCall(selection, targetWrite, batch, selector),
|
||||
),
|
||||
...newSetBatches.map((batch) => buildGlobalStaticSetCall(selection, batch)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,11 +278,10 @@ function findGroupOwningStaticWrite(
|
||||
* the timeline (matches the manual-drag UX). The global-set instant patch applies
|
||||
* it straight to the element so the first edit shows with no soft-reload flash.
|
||||
*/
|
||||
async function addGlobalStaticSet(
|
||||
function buildGlobalStaticSetCall(
|
||||
selection: DomEditSelection,
|
||||
batch: [string, number | string][],
|
||||
commit: Commit,
|
||||
): Promise<void> {
|
||||
): CommitMutationCall {
|
||||
const numericProps: SetPatchProps = {};
|
||||
for (const [k, v] of batch) {
|
||||
if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
|
||||
@@ -257,10 +290,10 @@ async function addGlobalStaticSet(
|
||||
// selector is the bare class an id-less element yields, which would hold every
|
||||
// sibling. No one-element form means no write at all (see writeTargetSelector).
|
||||
const target = writeTargetSelector(selection);
|
||||
if (!target) return;
|
||||
await commit(
|
||||
if (!target) throw new GsapEditBlockedError("no-selector");
|
||||
return {
|
||||
selection,
|
||||
{
|
||||
mutation: {
|
||||
type: "add",
|
||||
targetSelector: target,
|
||||
method: "set",
|
||||
@@ -268,7 +301,7 @@ async function addGlobalStaticSet(
|
||||
properties: Object.fromEntries(batch),
|
||||
global: true,
|
||||
},
|
||||
{
|
||||
options: {
|
||||
label: staticSetLabel(batch),
|
||||
softReload: true,
|
||||
...(Object.keys(numericProps).length > 0
|
||||
@@ -280,7 +313,7 @@ async function addGlobalStaticSet(
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert-if-flat, then write ALL props into ONE keyframe at the playhead. */
|
||||
@@ -418,6 +451,9 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
||||
selector,
|
||||
primaryProp,
|
||||
);
|
||||
if (!anim && !writeTargetSelector(selection)) {
|
||||
throw new GsapEditBlockedError("no-selector");
|
||||
}
|
||||
// Whether the element is animated at all. A 3D edit only creates/edits
|
||||
// keyframes when it IS — a static element (no keyframes on any of its tweens)
|
||||
// gets a `tl.set`, never new keyframes (matches manual drag / resize / rotate).
|
||||
@@ -472,12 +508,15 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Existing static hold on a NON-animated element — merge the props into the
|
||||
// same write (maybeAutoKeyframeSet no-ops when nothing else is keyframed).
|
||||
if (anim && isInstantHold(anim)) {
|
||||
await commitSetProps(
|
||||
// Static element (no keyframes anywhere) — persist as a `tl.set`, never
|
||||
// keyframes (incl. the no-animation case, which creates a fresh set).
|
||||
// Route the complete property set through the group-aware planner even
|
||||
// when pickBestAnimation found one existing set: a mixed X+width edit
|
||||
// must update the position set AND create a size set atomically rather
|
||||
// than contaminating the first set with a foreign property group.
|
||||
if (!elementHasKeyframes) {
|
||||
await commitStaticSet(
|
||||
selection,
|
||||
anim,
|
||||
propEntries,
|
||||
selector,
|
||||
selectedGsapAnimations,
|
||||
@@ -486,11 +525,12 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Static element (no keyframes anywhere) — persist as a `tl.set`, never
|
||||
// keyframes (incl. the no-animation case, which creates a fresh set).
|
||||
if (!elementHasKeyframes) {
|
||||
await commitStaticSet(
|
||||
// Existing static hold on an otherwise animated element — merge the props
|
||||
// into the same write, then auto-keyframe it against the sibling tween.
|
||||
if (anim && isInstantHold(anim)) {
|
||||
await commitSetProps(
|
||||
selection,
|
||||
anim,
|
||||
propEntries,
|
||||
selector,
|
||||
selectedGsapAnimations,
|
||||
@@ -509,7 +549,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
||||
// one-element form the edit is dropped rather than written onto every
|
||||
// class sibling (see writeTargetSelector).
|
||||
const newTweenTarget = writeTargetSelector(selection);
|
||||
if (selector && newTweenTarget) {
|
||||
if (newTweenTarget) {
|
||||
const template = selectedGsapAnimations.find((a) => !!a.keyframes);
|
||||
const tStart = template ? (resolveTweenStart(template) ?? 0) : 0;
|
||||
const tDur = template ? resolveTweenDuration(template) || 1 : 1;
|
||||
@@ -539,7 +579,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
bumpGsapCache();
|
||||
throw new GsapEditBlockedError("no-selector");
|
||||
} catch (error) {
|
||||
bumpGsapCache();
|
||||
throw error;
|
||||
|
||||
Reference in New Issue
Block a user