mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
* fix(studio): respect GSAP transform ownership * fix(studio): enforce GSAP edit ownership consistently
505 lines
18 KiB
TypeScript
505 lines
18 KiB
TypeScript
/**
|
|
* Low-level drag commit helpers for GSAP position mutations.
|
|
* Extracted from gsapRuntimeBridge.ts to keep file sizes under the 600-line limit.
|
|
*/
|
|
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
|
import type { DomEditSelection } from "../components/editor/domEditingTypes";
|
|
import {
|
|
STUDIO_ORIGINAL_WIDTH_ATTR,
|
|
STUDIO_ORIGINAL_HEIGHT_ATTR,
|
|
} from "../components/editor/manualEditsTypes";
|
|
import { usePlayerStore } from "../player/store/playerStore";
|
|
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
|
import { roundTo3 } from "../utils/rounding";
|
|
import { computeElementPercentage, writeTargetSelector } from "./gsapShared";
|
|
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
|
import type { RuntimeTweenChange } from "./gsapRuntimePatch";
|
|
import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction";
|
|
import { setPatchFromUpdateProperty } from "./gsapDragStaticSetHelpers";
|
|
import { GsapEditBlockedError } from "./gsapEditOutcome";
|
|
export {
|
|
findExistingPositionWrite,
|
|
findRotationSetAnimation,
|
|
findSizeSetAnimation,
|
|
} from "./gsapDragStaticSetHelpers";
|
|
export interface GsapDragCommitCallbacks {
|
|
commitMutation: (
|
|
selection: DomEditSelection,
|
|
mutation: Record<string, unknown>,
|
|
options: {
|
|
label: string;
|
|
coalesceKey?: string;
|
|
softReload?: boolean;
|
|
skipReload?: boolean;
|
|
beforeReload?: () => void;
|
|
/**
|
|
* Value-only fast path: when set, `runCommit` patches the changed tween in
|
|
* the preview runtime in place (instant, no re-run) and only falls back to
|
|
* the soft reload if the patch can't be safely applied. Attached only to
|
|
* value-only `set` commits; structural/keyframe commits omit it.
|
|
*/
|
|
instantPatch?: { selector: string; change: RuntimeTweenChange };
|
|
},
|
|
) => Promise<void>;
|
|
fetchAnimations?: () => Promise<GsapAnimation[]>;
|
|
}
|
|
|
|
/**
|
|
* The target for a tween these helpers are about to CREATE. Callers derive
|
|
* `selector` with `selectorFromSelection`, which hands back a bare class for an
|
|
* id-less element: authoring that widens a one-element drag/resize/rotate into a
|
|
* write over every sibling sharing the class. Retargets of an EXISTING tween
|
|
* must NOT come through here (they keep `anim.targetSelector`, so a tween the
|
|
* author aimed at a group stays aimed at it).
|
|
*
|
|
* Null means no one-element form exists, and every caller drops the commit
|
|
* rather than falling back to `selector` (see writeTargetSelector): the drag
|
|
* reverts on the next reload, which is recoverable, where a `.group` write is
|
|
* not.
|
|
*/
|
|
function newTweenTarget(selection: DomEditSelection): string | null {
|
|
return writeTargetSelector(selection);
|
|
}
|
|
|
|
// Re-export for backward compatibility with existing imports.
|
|
export function computeCurrentPercentage(
|
|
selection: DomEditSelection,
|
|
animation?: GsapAnimation,
|
|
): number {
|
|
return computeElementPercentage(usePlayerStore.getState().currentTime, selection, animation);
|
|
}
|
|
|
|
// When a drag edits a SELECTED keyframe, park the playhead on that keyframe's exact
|
|
// time. Otherwise the playhead can sit a frame outside the tween (e.g. 1.1666 vs a
|
|
// 1.2 start), so the post-commit reseek renders the element's base pose and the edit
|
|
// looks like it snapped away. Keeping the playhead on the edited keyframe avoids that.
|
|
export function parkPlayheadOnKeyframe(anim: GsapAnimation, pct: number): void {
|
|
const ts = resolveTweenStart(anim);
|
|
const td = resolveTweenDuration(anim);
|
|
if (ts == null || !td || td <= 0) return;
|
|
usePlayerStore.getState().requestSeek(roundTo3(ts + (pct / 100) * td));
|
|
}
|
|
|
|
async function replaceKeyframedPositionHold(
|
|
selection: DomEditSelection,
|
|
existingSet: GsapAnimation,
|
|
properties: { x: number; y: number },
|
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
): Promise<void> {
|
|
const target = newTweenTarget(selection);
|
|
if (!target) throw new GsapEditBlockedError("no-selector");
|
|
const persist = async (commit: GsapDragCommitCallbacks["commitMutation"]) => {
|
|
await commit(
|
|
selection,
|
|
{
|
|
type: "add",
|
|
targetSelector: target,
|
|
method: "set",
|
|
position: 0,
|
|
properties,
|
|
global: true,
|
|
},
|
|
{ label: "Move layer", skipReload: true },
|
|
);
|
|
await commit(
|
|
selection,
|
|
{ type: "delete", animationId: existingSet.id },
|
|
{ label: "Move layer", softReload: true },
|
|
);
|
|
};
|
|
|
|
if (isGestureTransactionCommit(commitMutation)) {
|
|
await persist(commitMutation);
|
|
return;
|
|
}
|
|
await runGestureTransaction({
|
|
element: selection.element,
|
|
label: "Move layer",
|
|
settle: () => undefined,
|
|
persist: async (commit) => persist(commit(commitMutation)),
|
|
restore: () => undefined,
|
|
});
|
|
}
|
|
|
|
// ── Dynamic keyframe materialization ──────────────────────────────────────
|
|
|
|
export async function materializeIfDynamic(
|
|
anim: GsapAnimation,
|
|
iframe: HTMLIFrameElement | null,
|
|
commitMutation: GsapDragCommitCallbacks["commitMutation"],
|
|
selection: DomEditSelection,
|
|
): Promise<string | void> {
|
|
if (!anim.hasUnresolvedKeyframes && !anim.hasUnresolvedSelector) return;
|
|
// Geometry commits must never rewrite runtime/computed source implicitly.
|
|
// The explicit Unroll action owns that source-destructive transition.
|
|
void iframe;
|
|
void commitMutation;
|
|
void selection;
|
|
throw new GsapEditBlockedError("source-uneditable");
|
|
}
|
|
|
|
// ── Drag → GSAP position math ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Commit a STATIC element drag as a `tl.set("#el",{x,y})` — the single-source
|
|
* position channel for elements with no position animation. Idempotent: a
|
|
* re-nudge of an element that already has a `set` UPDATES that set's x/y
|
|
* in one `update-properties` mutation rather than stacking a second set or
|
|
* converting it to keyframes (plan R2 / KTD3). New elements get one `add`
|
|
* mutation with `method:"set"` at position 0.
|
|
*/
|
|
export async function commitStaticGsapPosition(
|
|
selection: DomEditSelection,
|
|
studioOffset: { x: number; y: number },
|
|
gsapPos: { x: number; y: number },
|
|
selector: string,
|
|
existingSet: GsapAnimation | null,
|
|
callbacks: GsapDragCommitCallbacks,
|
|
): Promise<void> {
|
|
const { newX, newY } = computeDraggedGsapPosition(selection.element, studioOffset, gsapPos);
|
|
if (existingSet) {
|
|
if (existingSet.keyframes) {
|
|
// Keyframed zero-duration hold (drag-path corruption): can't update-property
|
|
// into keyframes. Add the replacement first so either failure leaves at
|
|
// least one hold on disk, then delete the corrupt tween in one transaction.
|
|
await replaceKeyframedPositionHold(
|
|
selection,
|
|
existingSet,
|
|
{ x: newX, y: newY },
|
|
callbacks.commitMutation,
|
|
);
|
|
return;
|
|
}
|
|
const mutation = {
|
|
type: "update-properties",
|
|
animationId: existingSet.id,
|
|
properties: { x: newX, y: newY },
|
|
} as const;
|
|
const global = !!existingSet.global;
|
|
await callbacks.commitMutation(selection, mutation, {
|
|
label: "Move layer",
|
|
softReload: true,
|
|
instantPatch: {
|
|
selector,
|
|
change: { kind: global ? "global-set" : "set", props: mutation.properties },
|
|
},
|
|
});
|
|
return;
|
|
}
|
|
// New static hold → a base `gsap.set` (off-timeline, no 0% keyframe marker), with
|
|
// an instant patch so the first nudge shows immediately (no soft-reload flash).
|
|
// The patch reuses the WRITTEN target so the runtime moves exactly the element
|
|
// the source write names.
|
|
const target = newTweenTarget(selection);
|
|
if (!target) throw new GsapEditBlockedError("no-selector");
|
|
await callbacks.commitMutation(
|
|
selection,
|
|
{
|
|
type: "add",
|
|
targetSelector: target,
|
|
method: "set",
|
|
position: 0,
|
|
properties: { x: newX, y: newY },
|
|
global: true,
|
|
},
|
|
{
|
|
label: "Move layer",
|
|
softReload: true,
|
|
instantPatch: {
|
|
selector: target,
|
|
change: { kind: "global-set", props: { x: newX, y: newY } },
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Commit a STATIC element rotation as a `tl.set("#el",{rotation})` — the single-
|
|
* source rotation channel for elements with no rotation animation (mirrors
|
|
* `commitStaticGsapPosition`). `newRotation` is the already-resolved absolute angle
|
|
* (current runtime rotation + drag delta). Idempotent: re-rotating an element that
|
|
* already has a rotation `set` UPDATES it in place (one `update-property`, rotation
|
|
* is a single value unlike x/y); a new element gets one `add` with `method:"set"`.
|
|
*/
|
|
export async function commitStaticGsapRotation(
|
|
selection: DomEditSelection,
|
|
newRotation: number,
|
|
selector: string,
|
|
existingSet: GsapAnimation | null,
|
|
callbacks: GsapDragCommitCallbacks,
|
|
): Promise<void> {
|
|
if (existingSet) {
|
|
// Derive the instantPatch from the SAME mutation object that's POSTed (single
|
|
// source of truth — see commitStaticGsapPosition), so the validated `value`
|
|
// flows into the patch and the two can't drift.
|
|
const rotationMutation = {
|
|
type: "update-property",
|
|
animationId: existingSet.id,
|
|
property: "rotation",
|
|
value: newRotation,
|
|
} as const;
|
|
await callbacks.commitMutation(selection, rotationMutation, {
|
|
label: "Rotate layer",
|
|
softReload: true,
|
|
// Value-only rotation set — patch the runtime in place (off-timeline gsap.set
|
|
// applies to the element directly; on-timeline tl.set patches its tween).
|
|
instantPatch: setPatchFromUpdateProperty(selector, rotationMutation, !!existingSet.global),
|
|
});
|
|
return;
|
|
}
|
|
// New static hold → off-timeline `gsap.set` (no 0% keyframe marker) + instant patch.
|
|
const target = newTweenTarget(selection);
|
|
if (!target) throw new GsapEditBlockedError("no-selector");
|
|
await callbacks.commitMutation(
|
|
selection,
|
|
{
|
|
type: "add",
|
|
targetSelector: target,
|
|
method: "set",
|
|
position: 0,
|
|
properties: { rotation: newRotation },
|
|
global: true,
|
|
},
|
|
{
|
|
label: "Rotate layer",
|
|
softReload: true,
|
|
instantPatch: {
|
|
selector: target,
|
|
change: { kind: "global-set", props: { rotation: newRotation } },
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Commit a STATIC element resize as a `tl.set("#el",{width,height})` — the
|
|
* single-source size channel for elements with no size animation (mirrors
|
|
* `commitStaticGsapPosition`). Use this instead of a single-stop `keyframes`
|
|
* tween: one keyframe at the playhead % renders NaN/0 at every other frame, so
|
|
* the element collapses/disappears (worst when resized off the 0% mark). A `set`
|
|
* holds the size at all times. Re-resizing an element that already has a size
|
|
* `set` UPDATES it in place with one `update-properties`; a new element
|
|
* gets one `add` with `method:"set"`.
|
|
*/
|
|
export async function commitStaticGsapSize(
|
|
selection: DomEditSelection,
|
|
size: { width: number; height: number },
|
|
selector: string,
|
|
existingSet: GsapAnimation | null,
|
|
callbacks: GsapDragCommitCallbacks,
|
|
): Promise<void> {
|
|
const width = Math.round(size.width);
|
|
const height = Math.round(size.height);
|
|
if (existingSet) {
|
|
await callbacks.commitMutation(
|
|
selection,
|
|
{
|
|
type: "update-properties",
|
|
animationId: existingSet.id,
|
|
properties: { width, height },
|
|
},
|
|
{ label: "Resize layer", softReload: true },
|
|
);
|
|
return;
|
|
}
|
|
const target = newTweenTarget(selection);
|
|
if (!target) throw new GsapEditBlockedError("no-selector");
|
|
await callbacks.commitMutation(
|
|
selection,
|
|
{
|
|
type: "add",
|
|
targetSelector: target,
|
|
method: "set",
|
|
position: 0,
|
|
properties: { width, height },
|
|
},
|
|
{ label: "Resize layer", softReload: true },
|
|
);
|
|
}
|
|
|
|
/** Rounded `n` when it's a positive finite number, else `fallback`. */
|
|
function positiveOr(n: number, fallback: number): number {
|
|
return Number.isFinite(n) && n > 0 ? Math.round(n) : fallback;
|
|
}
|
|
|
|
/**
|
|
* Prior size for a keyframed resize: the existing global set's value, else the
|
|
* element's pre-resize size (the draft saved it on the element before mutating
|
|
* el.style.width/height). Falls back to the new size when neither is available.
|
|
*/
|
|
function resolvePriorSize(
|
|
sizeSet: GsapAnimation | null,
|
|
el: Element | null | undefined,
|
|
fallbackW: number,
|
|
fallbackH: number,
|
|
): { width: number; height: number } {
|
|
if (sizeSet) {
|
|
return {
|
|
width: positiveOr(Number(sizeSet.properties.width), fallbackW),
|
|
height: positiveOr(Number(sizeSet.properties.height), fallbackH),
|
|
};
|
|
}
|
|
const ow = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_WIDTH_ATTR) ?? "");
|
|
const oh = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR) ?? "");
|
|
return { width: positiveOr(ow, fallbackW), height: positiveOr(oh, fallbackH) };
|
|
}
|
|
|
|
/**
|
|
* Resize an *animated* element by keyframing its size at the current playhead,
|
|
* instead of a global `gsap.set` hold. Builds a width/height keyframe tween
|
|
* aligned to the element's existing animation: every base keyframe keeps the
|
|
* prior size, only the keyframe nearest the playhead gets the new size — so
|
|
* resizing one keyframe leaves the others unchanged. Replaces any prior global
|
|
* size set. Returns false when there's no usable range (caller falls back to the
|
|
* static set).
|
|
*/
|
|
export async function commitKeyframedSizeFromResize(
|
|
selection: DomEditSelection,
|
|
size: { width: number; height: number },
|
|
selector: string,
|
|
sizeSet: GsapAnimation | null,
|
|
animatedTween: GsapAnimation,
|
|
callbacks: GsapDragCommitCallbacks,
|
|
): Promise<boolean> {
|
|
const ts = resolveTweenStart(animatedTween) ?? 0;
|
|
const td = resolveTweenDuration(animatedTween);
|
|
if (!(td > 0)) return false;
|
|
|
|
const newW = Math.round(size.width);
|
|
const newH = Math.round(size.height);
|
|
const prior = resolvePriorSize(sizeSet, selection.element, newW, newH);
|
|
|
|
const ct = usePlayerStore.getState().currentTime;
|
|
const pct = Math.max(0, Math.min(100, Math.round(((ct - ts) / td) * 1000) / 10));
|
|
|
|
// Base keyframe percentages from the animated tween (flat tween → 0 & 100),
|
|
// plus the endpoints and the playhead. Each keeps the prior size except the
|
|
// keyframe at the playhead, which gets the new size.
|
|
const pcts = new Set<number>(
|
|
animatedTween.keyframes?.keyframes.map((k) => k.percentage) ?? [0, 100],
|
|
);
|
|
pcts.add(0);
|
|
pcts.add(100);
|
|
pcts.add(pct);
|
|
const keyframes = Array.from(pcts)
|
|
.sort((a, b) => a - b)
|
|
.map((p) => ({
|
|
percentage: p,
|
|
properties: Math.abs(p - pct) < 0.05 ? { width: newW, height: newH } : { ...prior },
|
|
}));
|
|
|
|
// Add the size keyframe tween FIRST, then delete the old global hold. The gesture
|
|
// transport applies both in one ordered batch; a plain commit fallback keeps the
|
|
// same recoverable ordering. Only the transaction's result triggers the reload.
|
|
const addLabel = `Resize (size keyframe ${pct.toFixed(0)}%)`;
|
|
const target = newTweenTarget(selection);
|
|
if (!target) return false;
|
|
await callbacks.commitMutation(
|
|
selection,
|
|
{
|
|
type: "add-with-keyframes",
|
|
targetSelector: target,
|
|
position: roundTo3(ts),
|
|
duration: roundTo3(td),
|
|
keyframes,
|
|
},
|
|
sizeSet ? { label: addLabel, skipReload: true } : { label: addLabel, softReload: true },
|
|
);
|
|
if (sizeSet) {
|
|
await callbacks.commitMutation(
|
|
selection,
|
|
{ type: "delete", animationId: sizeSet.id },
|
|
{ label: "Resize layer", softReload: true },
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// ── Whole-path offset (plain drag on animated element) ──────────────────
|
|
|
|
/**
|
|
* Offset the entire animation path by the drag delta — every keyframe's x/y
|
|
* shifts together so the animation shape is preserved and the element can't
|
|
* dart off-screen. For flat tweens (no keyframes), convert first then shift.
|
|
*/
|
|
// fallow-ignore-next-line code-duplication
|
|
// fallow-ignore-next-line complexity
|
|
export async function commitWholePathOffset(
|
|
selection: DomEditSelection,
|
|
anim: GsapAnimation,
|
|
studioOffset: { x: number; y: number },
|
|
gsapPos: { x: number; y: number },
|
|
iframe: HTMLIFrameElement | null,
|
|
selector: string,
|
|
callbacks: GsapDragCommitCallbacks,
|
|
): Promise<void> {
|
|
const el = selection.element;
|
|
const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
|
|
el,
|
|
studioOffset,
|
|
gsapPos,
|
|
);
|
|
const deltaX = newX - baseGsapX;
|
|
// fallow-ignore-next-line code-duplication
|
|
const deltaY = newY - baseGsapY;
|
|
const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0;
|
|
const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0;
|
|
const restoreOffset = () => {
|
|
el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
|
|
el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
|
|
el.removeAttribute("data-hf-drag-initial-offset-x");
|
|
el.removeAttribute("data-hf-drag-initial-offset-y");
|
|
};
|
|
|
|
// fallow-ignore-next-line code-duplication
|
|
let effectiveAnim = anim;
|
|
if (anim.keyframes) {
|
|
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
|
if (newId) effectiveAnim = { ...anim, id: newId };
|
|
}
|
|
|
|
const ts = resolveTweenStart(effectiveAnim);
|
|
const td = resolveTweenDuration(effectiveAnim);
|
|
const ease = effectiveAnim.keyframes?.easeEach ?? effectiveAnim.ease;
|
|
|
|
let kfs = effectiveAnim.keyframes?.keyframes ?? [];
|
|
if (kfs.length === 0) {
|
|
const fromProps = effectiveAnim.fromProperties ?? {};
|
|
const toProps = effectiveAnim.properties ?? {};
|
|
const startX =
|
|
typeof fromProps.x === "number" ? fromProps.x : typeof toProps.x === "number" ? 0 : 0;
|
|
const startY =
|
|
typeof fromProps.y === "number" ? fromProps.y : typeof toProps.y === "number" ? 0 : 0;
|
|
const endX = typeof toProps.x === "number" ? toProps.x : startX;
|
|
const endY = typeof toProps.y === "number" ? toProps.y : startY;
|
|
kfs = [
|
|
{ percentage: 0, properties: { x: startX, y: startY } },
|
|
{ percentage: 100, properties: { x: endX, y: endY } },
|
|
];
|
|
}
|
|
|
|
const shifted = kfs.map((kf) => ({
|
|
percentage: kf.percentage,
|
|
properties: {
|
|
...kf.properties,
|
|
x: roundTo3((typeof kf.properties.x === "number" ? kf.properties.x : 0) + deltaX),
|
|
y: roundTo3((typeof kf.properties.y === "number" ? kf.properties.y : 0) + deltaY),
|
|
},
|
|
...(kf.ease ? { ease: kf.ease } : {}),
|
|
}));
|
|
|
|
await callbacks.commitMutation(
|
|
selection,
|
|
{
|
|
type: "replace-with-keyframes",
|
|
animationId: effectiveAnim.id,
|
|
targetSelector: effectiveAnim.targetSelector,
|
|
position: roundTo3(ts ?? 0),
|
|
duration: roundTo3(td || 1),
|
|
keyframes: shifted,
|
|
ease,
|
|
},
|
|
{ label: "Move animation path", softReload: true, beforeReload: restoreOffset },
|
|
);
|
|
}
|