mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
feat(studio): instant, flicker-free manual editing (#1605)
* chore(producer): shim __filename/__dirname in the CJS banner Bundled CJS deps like wawoff2 call __dirname; without the shim they throw "__dirname is not defined in ES module" at render time. Also ignore .zed/. * chore(producer): use a template literal for the CJS banner (review nit) * feat(core): add GSAP keyframe + motion-path source mutations Array-form keyframe removal in both the recast and acorn writers, plus update/add/remove-motion-path-point and add-motion-path. Exclude _auto and data from tween property-group classification. * fix(core): address #1554 review — data-exclusion test, split-fix doc, motion-path sentinel, parity blocks - Regression test for the `data` GSAP-key exclusion (parallel to _auto). - splitAnimationsInScript: documented that .fromTo()/.to() correctly stay out of the from-branch (only .from() reverts) and the <= boundary; added mid-flight straddle tests. - addMotionPathToScript failure path returns id: null (was empty-string sentinel); caller updated. - Parity blocks for addKeyframeToScript array-form + updateKeyframeInScript (mirroring removeKeyframeFromScript). Surfaced a latent acorn array-form partial-props merge bug — documented as it.skip with a ready assertion (acorn cutover follow-up). * feat(core): route motion-path mutations through studio-api + fix clip stamping Wire the new mutations into the file save route. Only authored clips suppress descendant stamping, so auto-stamped animated scenes can inline-expand. Hide in-flow timed clips with `display:none` only when they are LEAF clips (no nested timed clips). `display:none` on a container removes its whole subtree, hiding descendants that are still inside their own visibility window — e.g. an in-flow composition root whose effective window clamps to the timeline end would black out a child video that should still show (the hdr-hlg regression). Containers keep `visibility:hidden`, which a visible descendant can override; only leaves leave the flow, which is all the split-overlap case needs. * feat(core): strip legacy path-offset/rotation + drop obsolete studio lint rule A position or rotation add/set mutation makes the GSAP timeline the single source of truth for that channel, so any lingering --hf-studio-offset / --hf-studio-rotation CSS var must be cleared to avoid double-applying. stripStudioEditsFromTarget now clears both channels, and the add-strip fires for the position AND rotation property groups. Also removes the obsolete `gsap_studio_edit_blocked` lint rule: it warned that Studio cannot save drag/resize edits to elements in a registered timeline — the exact premise the single-source work inverts (the timeline is now the edit target). Removed the rule, its now-unused TIMELINE_REGISTRY_ASSIGN_PATTERN import, and its 5 tests. * fix(core): address #1555 review — complete hold-sync, invalidate clip cache, strip rotation channel - HOLD_SYNC_MUTATION_TYPES: add add-motion-path (load-bearing — addMotionPathToScript authors past t=0 → first-frame snap-to-(0,0) without the hold), update-meta, shift-positions, scale-positions, split-animations. (add stays out: flat tweens only, syncPositionHoldsBeforeKeyframes is a no-op for non-keyframed tweens.) - init.ts: timedClip in-flow/leaf WeakMaps now invalidate on clipTreeSignature change; visible/hidden branches both go through isTimedClipInFlow (was .get() by accident). - keyframesWriteRotation mirrors keyframesWritePosition so a rotation-only keyframe set strips the stale --hf-studio-rotation channel. * feat(studio): GSAP runtime read layer + shared helpers * fix(studio): address #1607 review — cold-parse vs fetch-error budgets, isZeroDurationSet, array-ease tests - useGsapAnimationFetchFallback: discriminate resolved/fetch-error/cold; only the cold (warm-but-zero) race gets the full ~600ms retry budget — a hard fetch error retries once. - Extract isZeroDurationSet (was !(duration>0) duplicated); rejects NaN, documents intent. - parsePercentageKeyframes: cite GSAP even-index spread; tests that a per-entry/interior ease is stripped without shifting the other keyframes' percentages. * feat(studio): GSAP drag/commit/bridge editing infra * fix(studio): address #1608 review — facade awaits commit, strict stale-parse guard, clearProps restore BLOCKER: useSafeGsapCommitMutation now RETURNS the (.catch-chained) commit promise and the commitMutation facade awaits it — so await session.commitMutation(...) resolves AFTER the server save, fixing both consumers (useEnableKeyframes + useGestureCommit's showToast/requestSeek/idle, which were firing before the save landed). SafeGsapCommitMutation return type widened void→Promise<void> (fire-and-forget consumers ignore it). - stale-parse guard uses hasNonHoldTweenForElement (a leftover hold set no longer counts as live). - commitFlatViaKeyframes snapshots dragged gsap values before clearProps + restores after seek, so a failed commit leaves the dropped pose, not a cleared element. * feat(studio): motion-path geometry + commit helpers * docs(studio): address #1609 review — document occlusion fade-in invariant, donut limit, nearestPointOnPath t-semantics * feat(studio): on-canvas motion-path overlay * fix(studio): address #1610 review — scope dblclick to pan-surface, kind-aware geometry guard, gate createMode, screen-space drag threshold * feat(studio): keyframes flag, gesture recording + timeline/selection refinements * fix(studio): address #1611 review — fetch-first keyframe path, gated hydration, dev-gated debug + gesture warn, per-group gesture tweens - useEnableKeyframes: parse current source first (null-vs-[] distinction) so a delete-all's empty parse isn't overridden by a stale selectedGsapAnimations cache. - useStudioUrlState: freeze the hydration effect's time dep once hydrated (was re-running every tick). - useGestureRecording: dev-gated console.warn when the live-preview runtime throws (was silent). - playerStore: gate window.__playerStore behind dev (guarded import.meta.env.DEV). - useGestureCommit: partition recorded keyframes by property group → one add-with-keyframes per group, so a mixed gesture no longer yields an untagged legacy tween. * feat(studio): single-source manual offset + rotation via the GSAP timeline Dragging or rotating an element writes into the GSAP timeline (the single source of truth) instead of a parallel --hf-studio-offset / --hf-studio-rotation CSS var: static elements commit a tl.set (idempotent on re-edit), tweened elements edit keyframes, and the live preview moves via gsap.set so what you see equals what is written and renders. Removes the dual-channel CSS-var/transform reconciliation behind the fling / disappear / runaway / double-stack / wrong-start bug class — for BOTH position and rotation (gesture base read from the gsap transform, gsap.set live preview, tl.set/ keyframe commit, dropped the handleDom*Commit CSS fallbacks). Subcompositions edit the same single-source way, which surfaced and fixes: - resolve a subcomp element's source file via the composition-id map (the runtime drops the source linkage when inlining the subcomposition); - a selected element's selection box AND motion path use basic visibility, not the occlusion heuristic (a backgroundless opacity-1 scene above it is not an opaque cover); - soft reload rebuilds ONLY the committed composition's timeline, leaving other compositions' timelines intact (no cross-composition revert); - read keyframes from the element's OWN composition timeline (scan all timelines, not the first unstable key); - delete-all uses a soft reload too, so editing no longer hard-reloads the iframe. * fix(studio): address #1567 review — drop drag-intercept flag, harden softReload onerror, tighten runtime ladder, per-group gestures - DROP STUDIO_GSAP_DRAG_INTERCEPT_ENABLED: single-source GSAP intercept is the only position/rotation channel; the false branch silently killed drag+rotate (and let GSAP elements into the keyframe-corrupting CSS path). Removed flag + dead branch + env def + tests. - gsapSoftReload: plugin onerror no longer fakes success — signals onAsyncFailure so the caller full-reloads; honors __hfMotionPathPluginLoading so a concurrent reload can't queue a dup script. - gsapDragCommit: resolveDragRuntime narrows the as-any ladder; a mid-seek throw logs + drops partial reads (no phantom identity) and re-applies the drag override in finally. - MotionPathOverlay: park-timer cleanup keyed on animId change. - useGestureCommit: partitionKeyframesByGroup wraps the add-with-keyframes sites (per #1611 review). * feat(studio): patchRuntimeTweenInPlace — update a tween's values in place Defensive runtime helper: locate the element's tween in window.__timelines via the shared resolveRuntimeTween scan, update its set/keyframe vars, invalidate, and re-seek the playhead — without re-running the whole composition. Returns false (caller falls back to soft reload) for any shape it can't safely patch (no tween, dynamic/computed keyframes, motionPath arc, channel mismatch, or any error). Foundation for instant, flicker-free manual edits. * fix(studio): address #1612 review — channel-aware set resolution + decline dynamic-expression patches - resolveRuntimeTween gains an optional channels[] hint; for kind:set it prefers the set whose vars carry one of the patched channels and never returns a disjoint-only set (e.g. won't write {x,y} into a co-located {rotation} set). patchRuntimeTweenInPlace derives channels from the props. - patchSet declines (returns false → soft reload) when overwriting a string/dynamic vars[ch], instead of silently dropping the computed expression. * feat(studio): instantPatch fast path in runCommit A commit carrying an instantPatch option tries patchRuntimeTweenInPlace first; on success the preview updates in place with NO reload (instant), on false it falls back to the existing soft reload. Extracts the preview-sync tail into a testable applyPreviewSync helper. No behavior change when instantPatch is absent. * feat(studio): route static position/rotation set drags through instantPatch Static-element position and rotation set commits now attach instantPatch{selector, change:{kind:set}} so the drag updates in place with no reload. Structural ops (new tween add, delete-all, convert/split/materialize) and keyframe edits deliberately omit it and keep the soft reload — keyframe instant-patch needs object-form keyframe support in patchRuntimeTweenInPlace (deferred). * fix(studio): address #1613 review — derive instantPatch from the mutation, patch both coalesced commits, wire onAsyncFailure - commitStaticGsapPosition/Rotation derive instantPatch.change.props from the actual update-property mutation(s) sent (one source of truth → findUnsafeMutationValues-validated values flow into the patch; can't drift). - Coalesced x/y: the intermediate x commit also carries instantPatch{x}, the y commit {x,y}, so a second-POST failure still leaves the preview patched for what persisted. - applyPreviewSync passes reloadPreview as onAsyncFailure (plugin-CDN load error → full reload); per U4 the synchronous false still does NOT escalate. - (channel disambiguation from #1612 verified end-to-end: {x,y}→position set, {rotation}→rotation set.) * feat(studio): no full iframe remount for soft-reloadable edits A softReload edit (and the SDK single-script refresh) no longer escalates to a full reloadPreview() iframe remount when applySoftReload returns false — the live gsap.set already shows the value, and a remount is the worst flash + re-inlines subcomps (reverting their keyframes). verifyTimelinesPopulated now checks the expected target keys the re-run registers, so a correct scoped re-run doesn't spuriously report empty. Full reload stays only for the structural (no-softReload) and ambiguous-script paths. * feat(studio): pre-load MotionPathPlugin so motion-path edits don't async-flash ensureMotionPathPluginLoaded() runs once at the preview iframe-load seam (NLELayout onIframeLoad), eagerly loading + registering MotionPathPlugin without killing the timeline. So when a user adds a motion path to a composition that didn't originally use one, the soft reload runs synchronously instead of taking the kill-then-await-CDN async path (the flash). Idempotent + defensive; the existing async fallback stays for genuine cold-start/CDN-failure. * fix(studio): don't re-save + reload when source editor syncs externally The SourceEditor's CodeMirror update listener fired onChange on ANY docChanged — including the programmatic dispatch that syncs external content (e.g. a manual-edit commit writing the source back into the open editor). That made the editor re-save the file and bump refreshKey, fully reloading the preview iframe on every drag/keyframe edit — defeating the in-place instant patch and causing the flash. Annotate the programmatic sync (ExternalSync) and skip onChange for it, so only real keystrokes save. * fix(core): inject MotionPathPlugin into preview when a composition uses motionPath A studio-created motion path writes a gsap motionPath tween into the single-source timeline, but the preview HTML only loaded gsap core — so the first render threw "Invalid property motionPath ... Missing plugin?". Detect motionPath usage and inject MotionPathPlugin right after the composition's gsap script, version-matched to it. * fix(studio): dedup __hfMotionPathPluginLoading type decl (restack artifact) * fix(studio): address #1605 review — distinguish soft-reload failure modes + observability, SourceEditor focus guard BLOCKER: applySoftReload now returns SoftReloadResult ('applied' | 'verify-failed' | 'cannot-soft-reload') instead of a bare bool. applyPreviewSync + sdkRefresh escalate to a full reloadPreview() on the PERMANENT 'cannot-soft-reload' (no gsap/rebind hook/scopable key/script, or sync re-run threw) — fixing the silent-stale-preview U4 dropped — but still suppress the TRANSIENT 'verify-failed' (live gsap.set is correct). Telemetry: gsap_soft_reload_outcome (origin/result/escalated) + gsap_instant_patch_fallback, so the U4 invariant is enforced, not asserted. - SourceEditor: skip the programmatic external-sync replace while the editor is focused, so an in-flight commit doesn't clobber the user's uncommitted keystrokes (ExternalSync kept for unfocused). - Verified ensureMotionPathPluginLoaded already guards __hfMotionPathPluginLoading (no double-append). * fix(core): align __clipTree and __clipManifest ids via stableClipId Timeline inline expansion was dead for nested children inside index.html: the tree keyed id-less elements by a synthetic __clip-N while the manifest keyed them null, so parent<->child never joined. Both now resolve identity through stableClipId (id || data-hf-id), which every generated element has. * fix(core): strip baked runtime + tag comp root in preview assembly Comps that ship a baked inline runtime were double-loaded (preview injects its own) and the baked copy failed to parse inline (Unexpected token '<'). Strip it in buildSubCompositionHtml + the disk-fallback preview path. Also tag the comp root with data-composition-file so the studio resolves a comp's top-level elements to the right source file instead of defaulting to index.html (which made the GSAP panel parse the wrong, multi-timeline file). * feat(studio): set motion-path destination from a toolbar toggle Replaces the double-click-on-canvas UX (which painted text over the preview) with a 'Set motion destination' toggle next to Snap/Grid, shown only when the selected element can take a path. While armed, one canvas press places the destination. Also removes the dead TimelinePropertyRows component. * fix(studio): center timeline keyframe diamonds on their percentage Dropped clampDiamondLeft, which forced boundary keyframes fully inside the clip so a 0% diamond sat half a diamond right of the 0% point. Each diamond's midpoint now sits exactly on its % (the clip is overflow-visible). * fix(studio): resize static elements via tl.set, not a single-stop keyframes tween Resizing an element with no size animation wrote keyframes:{ <playhead%>: {width,height} } — one mid-point stop GSAP can't interpolate, so it rendered NaN/0 dimensions at every other frame and the element vanished (worst off 0%). Added commitStaticGsapSize (mirrors commitStaticGsapPosition): a static resize now writes tl.set({width,height}), held at all frames; re-resizing updates it in place. * fix(studio): negative-cache failed media probes Only successful probes were cached, so CORS/404 cross-origin media was re-probed every rAF-driven timeline re-derive, flooding the console. Remember failed URLs and skip them. * fix(studio): type window.setTimeout handle as number ReturnType<typeof window.setTimeout> infers NodeJS.Timeout when @types/node is present and clashes with the DOM number the call returns. Type it number. * fix(studio): drag/resize disappearance, stale-ID duplicates, soft-reload clearProps - Fix soft-reload clearProps destroying element inline styles — save cssText, clear, restore, strip only transform - Fix resize no-op on re-resize: delete+add instead of two update-property - Route set tweens through static resize path (convertToKeyframes skips sets) - Re-fetch animation ID before drag commit to prevent stale-ID duplicates - Guard editDebugLog for Node test environments - Fix NLELayout setState-during-render (move reset to useEffect) - Stop SnapToolbar pointer events propagating to canvas deselect handler - Enable click-to-add waypoints on cubic motion paths - Add whole-path drag offset (Alt+drag shifts all keyframes together) - Add Canvas shortcuts section to ShortcutsPanel - Extract useMotionPathData + commitGsapPositionFromDrag (filesize compliance) - Delete dead code (getElementDepth, isElementVisibleInPreview, unused exports)
This commit is contained in:
@@ -9,6 +9,8 @@ import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyf
|
||||
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
|
||||
import { roundTo3 } from "../utils/rounding";
|
||||
import { computeElementPercentage } from "./gsapShared";
|
||||
import { computeDraggedGsapPosition } from "./draggedGsapPosition";
|
||||
import type { RuntimeTweenChange, SetPatchProps } from "./gsapRuntimePatch";
|
||||
export interface GsapDragCommitCallbacks {
|
||||
commitMutation: (
|
||||
selection: DomEditSelection,
|
||||
@@ -19,6 +21,13 @@ export interface GsapDragCommitCallbacks {
|
||||
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[]>;
|
||||
@@ -32,6 +41,17 @@ export function computeCurrentPercentage(
|
||||
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));
|
||||
}
|
||||
|
||||
// ── Dynamic keyframe materialization ──────────────────────────────────────
|
||||
|
||||
export async function materializeIfDynamic(
|
||||
@@ -77,198 +97,262 @@ export async function materializeIfDynamic(
|
||||
);
|
||||
}
|
||||
|
||||
// ── Extend tween ──────────────────────────────────────────────────────────
|
||||
// ── Drag → GSAP position math ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extend a tween's time range to cover `targetTime`, remap all existing
|
||||
* keyframe percentages to preserve their absolute positions, then add
|
||||
* a new keyframe at the target time.
|
||||
*/
|
||||
async function extendTweenAndAddKeyframe(
|
||||
selection: DomEditSelection,
|
||||
anim: GsapAnimation,
|
||||
properties: Record<string, number>,
|
||||
targetTime: number,
|
||||
tweenStart: number,
|
||||
tweenDuration: number,
|
||||
callbacks: GsapDragCommitCallbacks,
|
||||
beforeReload?: () => void,
|
||||
): Promise<void> {
|
||||
const tweenEnd = tweenStart + tweenDuration;
|
||||
const newStart = Math.min(targetTime, tweenStart);
|
||||
const newEnd = Math.max(targetTime, tweenEnd);
|
||||
const newDuration = Math.max(0.01, newEnd - newStart);
|
||||
const existingKfs = anim.keyframes?.keyframes ?? [];
|
||||
const remappedKfs: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
||||
[];
|
||||
for (const kf of existingKfs) {
|
||||
const absTime = tweenStart + (kf.percentage / 100) * tweenDuration;
|
||||
const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10;
|
||||
remappedKfs.push({ percentage: newPct, properties: { ...kf.properties } });
|
||||
}
|
||||
|
||||
const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10;
|
||||
remappedKfs.push({ percentage: targetPct, properties });
|
||||
|
||||
remappedKfs.sort((a, b) => a.percentage - b.percentage);
|
||||
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "replace-with-keyframes",
|
||||
animationId: anim.id,
|
||||
targetSelector: anim.targetSelector,
|
||||
position: roundTo3(newStart),
|
||||
duration: roundTo3(newDuration),
|
||||
keyframes: remappedKfs,
|
||||
},
|
||||
{ label: `Move layer (extended keyframe)`, softReload: true, beforeReload },
|
||||
);
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async function commitKeyframedPosition(
|
||||
selection: DomEditSelection,
|
||||
anim: GsapAnimation,
|
||||
properties: Record<string, number>,
|
||||
callbacks: GsapDragCommitCallbacks,
|
||||
beforeReload?: () => void,
|
||||
): Promise<void> {
|
||||
const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState();
|
||||
const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim);
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "add-keyframe",
|
||||
animationId: anim.id,
|
||||
percentage: pct,
|
||||
properties,
|
||||
},
|
||||
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload },
|
||||
);
|
||||
if (activeKeyframePct != null) setActiveKeyframePct(null);
|
||||
/** The shape of an `update-property` mutation a static-set nudge POSTs. */
|
||||
interface UpdatePropertyMutation {
|
||||
type: "update-property";
|
||||
animationId: string;
|
||||
property: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* For flat to()/set() tweens, convert to keyframes first so we can place the
|
||||
* drag position at the current percentage.
|
||||
* Build the `instantPatch` for a value-only `tl.set` from the SAME
|
||||
* `update-property` mutation(s) that are POSTed — so the patch can never carry a
|
||||
* value the source write didn't (one source of truth). Each mutation contributes
|
||||
* its `{property: value}` channel to the patch's props.
|
||||
*/
|
||||
// fallow-ignore-next-line complexity
|
||||
async function commitFlatViaKeyframes(
|
||||
function setPatchFromUpdateProperties(
|
||||
selector: string,
|
||||
mutations: UpdatePropertyMutation[],
|
||||
): { selector: string; change: RuntimeTweenChange } {
|
||||
const props: SetPatchProps = {};
|
||||
for (const m of mutations) props[m.property as keyof SetPatchProps] = m.value;
|
||||
return { selector, change: { kind: "set", props } };
|
||||
}
|
||||
|
||||
/** Single-mutation convenience over {@link setPatchFromUpdateProperties}. */
|
||||
function setPatchFromUpdateProperty(
|
||||
selector: string,
|
||||
mutation: UpdatePropertyMutation,
|
||||
): { selector: string; change: RuntimeTweenChange } {
|
||||
return setPatchFromUpdateProperties(selector, [mutation]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the studio position-hold `set` for a selector — a `tl.set("#el",{x,y})`
|
||||
* with no duration. This is what a static-element nudge writes/updates.
|
||||
*/
|
||||
function findPositionSetAnimation(
|
||||
animations: GsapAnimation[],
|
||||
selector: string,
|
||||
): GsapAnimation | null {
|
||||
return (
|
||||
animations.find(
|
||||
(a) =>
|
||||
a.method === "set" &&
|
||||
a.targetSelector === selector &&
|
||||
("x" in a.properties || "y" in a.properties),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* (two `update-property` mutations) 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,
|
||||
anim: GsapAnimation,
|
||||
properties: Record<string, number>,
|
||||
studioOffset: { x: number; y: number },
|
||||
gsapPos: { x: number; y: number },
|
||||
selector: string,
|
||||
existingSet: GsapAnimation | null,
|
||||
callbacks: GsapDragCommitCallbacks,
|
||||
beforeReload?: () => void,
|
||||
iframe?: HTMLIFrameElement | null,
|
||||
selector?: string,
|
||||
): Promise<void> {
|
||||
const ct = usePlayerStore.getState().currentTime;
|
||||
const ts = resolveTweenStart(anim);
|
||||
const td = resolveTweenDuration(anim);
|
||||
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
||||
|
||||
// Read the runtime position at the tween's start time so the 0% keyframe
|
||||
// captures the actual interpolated value (e.g. x=300 after a preceding slide),
|
||||
// not the identity value (x=0) that a blind convert would produce.
|
||||
const resolvedFromValues: Record<string, number | string> = {};
|
||||
if (iframe && selector && ts !== null) {
|
||||
try {
|
||||
const iframeWin = iframe.contentWindow as any;
|
||||
const gsapLib = iframeWin?.gsap;
|
||||
const el = iframe.contentDocument?.querySelector(selector);
|
||||
const timelines = iframeWin?.__timelines;
|
||||
const mainTl = timelines ? (Object.values(timelines)[0] as any) : null;
|
||||
if (gsapLib && el && mainTl?.seek) {
|
||||
mainTl.seek(ts);
|
||||
for (const key of Object.keys(properties)) {
|
||||
const v = Number(gsapLib.getProperty(el, key));
|
||||
if (Number.isFinite(v)) resolvedFromValues[key] = roundTo3(v);
|
||||
}
|
||||
mainTl.seek(ct);
|
||||
}
|
||||
} catch {
|
||||
/* iframe access failed — fall back to identity values */
|
||||
}
|
||||
const { newX, newY } = computeDraggedGsapPosition(selection.element, studioOffset, gsapPos);
|
||||
if (existingSet) {
|
||||
// Update in place — two single-property mutations (the API updates one prop
|
||||
// per call). Coalesce them and reload only after the second lands.
|
||||
const coalesceKey = `gsap:set-nudge:${existingSet.id}`;
|
||||
// Build each mutation FIRST, then derive its instantPatch from the SAME
|
||||
// object that's POSTed — so a future caller can't ship a clean mutation with
|
||||
// a stale/malformed patch (the validated `value` flows straight into the
|
||||
// patch). `findUnsafeMutationValues` validates the mutation upstream.
|
||||
const xMutation = {
|
||||
type: "update-property",
|
||||
animationId: existingSet.id,
|
||||
property: "x",
|
||||
value: newX,
|
||||
} as const;
|
||||
const yMutation = {
|
||||
type: "update-property",
|
||||
animationId: existingSet.id,
|
||||
property: "y",
|
||||
value: newY,
|
||||
} as const;
|
||||
// Patch BOTH coalesced commits. If the SECOND POST fails server-side, the
|
||||
// first (x) already persisted — patching its commit too means the live
|
||||
// preview still reflects what DID persist. The x commit carries skipReload
|
||||
// (no reload), so its instantPatch gives instant feedback without a reload;
|
||||
// the y commit triggers the soft reload (skipped when the patch applies).
|
||||
await callbacks.commitMutation(selection, xMutation, {
|
||||
label: "Move layer",
|
||||
skipReload: true,
|
||||
coalesceKey,
|
||||
instantPatch: setPatchFromUpdateProperty(selector, xMutation),
|
||||
});
|
||||
await callbacks.commitMutation(selection, yMutation, {
|
||||
label: "Move layer",
|
||||
softReload: true,
|
||||
coalesceKey,
|
||||
// Final commit of the coalesced x/y pair: carry both channels so the
|
||||
// runtime `tl.set` lands the complete {x,y} pose in place.
|
||||
instantPatch: setPatchFromUpdateProperties(selector, [xMutation, yMutation]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "add",
|
||||
targetSelector: selector,
|
||||
method: "set",
|
||||
position: 0,
|
||||
properties: { x: newX, y: newY },
|
||||
},
|
||||
{ label: "Move layer", softReload: true },
|
||||
);
|
||||
}
|
||||
|
||||
if (outsideRange && ts !== null) {
|
||||
// Outside the tween's range: add a brand new keyframed tween at the drag
|
||||
// time instead of extending/replacing the existing one. This keeps all
|
||||
// existing tweens untouched and creates a clean hold at the dragged position.
|
||||
const tweenEnd = ts + td;
|
||||
const holdStart = ct > tweenEnd ? tweenEnd : ct;
|
||||
const holdEnd = ct > tweenEnd ? ct : ts;
|
||||
const holdDur = Math.max(0.01, holdEnd - holdStart);
|
||||
const kfs =
|
||||
ct > tweenEnd
|
||||
? [
|
||||
{ percentage: 0, properties: resolvedFromValues },
|
||||
{ percentage: 100, properties },
|
||||
]
|
||||
: [
|
||||
{ percentage: 0, properties },
|
||||
{ percentage: 100, properties: resolvedFromValues },
|
||||
];
|
||||
console.log(
|
||||
"[drag:5] outside range — adding new tween",
|
||||
JSON.stringify({
|
||||
ct,
|
||||
ts,
|
||||
td,
|
||||
holdStart: roundTo3(holdStart),
|
||||
holdDur: roundTo3(holdDur),
|
||||
from: resolvedFromValues,
|
||||
to: properties,
|
||||
}),
|
||||
export { findPositionSetAnimation };
|
||||
|
||||
function findRotationSetAnimation(
|
||||
animations: GsapAnimation[],
|
||||
selector: string,
|
||||
): GsapAnimation | null {
|
||||
return (
|
||||
animations.find(
|
||||
(a) => a.method === "set" && a.targetSelector === selector && "rotation" in a.properties,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 `tl.set` rotation in place.
|
||||
instantPatch: setPatchFromUpdateProperty(selector, rotationMutation),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "add",
|
||||
targetSelector: selector,
|
||||
method: "set",
|
||||
position: 0,
|
||||
properties: { rotation: newRotation },
|
||||
},
|
||||
{ label: "Rotate layer", softReload: true },
|
||||
);
|
||||
}
|
||||
|
||||
export { findRotationSetAnimation };
|
||||
|
||||
function findSizeSetAnimation(animations: GsapAnimation[], selector: string): GsapAnimation | null {
|
||||
return (
|
||||
animations.find(
|
||||
(a) =>
|
||||
a.method === "set" &&
|
||||
a.targetSelector === selector &&
|
||||
("width" in a.properties || "height" in a.properties),
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (two `update-property`, like x/y); 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: "delete", animationId: existingSet.id },
|
||||
{ label: "Resize layer", skipReload: true },
|
||||
);
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "add-with-keyframes",
|
||||
targetSelector: anim.targetSelector,
|
||||
position: roundTo3(holdStart),
|
||||
duration: roundTo3(holdDur),
|
||||
keyframes: kfs,
|
||||
type: "add",
|
||||
targetSelector: selector,
|
||||
method: "set",
|
||||
position: 0,
|
||||
properties: { width, height },
|
||||
},
|
||||
{ label: "Move layer (new keyframe)", softReload: true, beforeReload },
|
||||
{ label: "Resize layer", softReload: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Inside range: convert the flat tween to keyframes, then add at current %.
|
||||
const coalesceKey = `gsap:convert-drag:${anim.id}`;
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "convert-to-keyframes",
|
||||
animationId: anim.id,
|
||||
...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}),
|
||||
type: "add",
|
||||
targetSelector: selector,
|
||||
method: "set",
|
||||
position: 0,
|
||||
properties: { width, height },
|
||||
},
|
||||
{ label: "Convert to keyframes for drag", skipReload: true, coalesceKey },
|
||||
);
|
||||
const pct = computeCurrentPercentage(selection, anim);
|
||||
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "add-keyframe",
|
||||
animationId: anim.id,
|
||||
percentage: pct,
|
||||
properties,
|
||||
},
|
||||
{ label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload, coalesceKey },
|
||||
{ label: "Resize layer", softReload: true },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main drag commit ──────────────────────────────────────────────────────
|
||||
export { findSizeSetAnimation };
|
||||
|
||||
// ── Whole-path offset (plain drag on animated element) ──────────────────
|
||||
|
||||
/**
|
||||
* Compute the new GSAP position values from runtime-read positions + drag
|
||||
* offset, then commit the mutation to the GSAP script.
|
||||
* 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 complexity
|
||||
export async function commitGsapPositionFromDrag(
|
||||
export async function commitWholePathOffset(
|
||||
selection: DomEditSelection,
|
||||
anim: GsapAnimation,
|
||||
studioOffset: { x: number; y: number },
|
||||
@@ -277,24 +361,16 @@ export async function commitGsapPositionFromDrag(
|
||||
selector: string,
|
||||
callbacks: GsapDragCommitCallbacks,
|
||||
): Promise<void> {
|
||||
const rotStyle = selection.element.style.getPropertyValue("--hf-studio-rotation");
|
||||
const rotDeg = Number.parseFloat(rotStyle) || 0;
|
||||
const rad = (-rotDeg * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
const el = selection.element;
|
||||
const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition(
|
||||
el,
|
||||
studioOffset,
|
||||
gsapPos,
|
||||
);
|
||||
const deltaX = newX - baseGsapX;
|
||||
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 deltaX = studioOffset.x - origX;
|
||||
const deltaY = studioOffset.y - origY;
|
||||
const adjX = deltaX * cos - deltaY * sin;
|
||||
const adjY = deltaX * sin + deltaY * cos;
|
||||
const parsedBaseX = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "");
|
||||
const parsedBaseY = Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "");
|
||||
const baseGsapX = Number.isFinite(parsedBaseX) ? parsedBaseX : gsapPos.x;
|
||||
const baseGsapY = Number.isFinite(parsedBaseY) ? parsedBaseY : gsapPos.y;
|
||||
const newX = Math.round(baseGsapX + adjX);
|
||||
const newY = Math.round(baseGsapY + adjY);
|
||||
const restoreOffset = () => {
|
||||
el.style.setProperty("--hf-studio-offset-x", `${origX}px`);
|
||||
el.style.setProperty("--hf-studio-offset-y", `${origY}px`);
|
||||
@@ -302,141 +378,53 @@ export async function commitGsapPositionFromDrag(
|
||||
el.removeAttribute("data-hf-drag-initial-offset-y");
|
||||
};
|
||||
|
||||
const ct = usePlayerStore.getState().currentTime;
|
||||
let effectiveAnim = anim;
|
||||
if (anim.keyframes) {
|
||||
const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection);
|
||||
const effectiveAnim = newId ? { ...anim, id: newId } : anim;
|
||||
const dragProps: Record<string, number> = { x: newX, y: newY };
|
||||
|
||||
const ts = resolveTweenStart(effectiveAnim);
|
||||
const td = resolveTweenDuration(effectiveAnim);
|
||||
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
||||
if (outsideRange) {
|
||||
await extendTweenAndAddKeyframe(
|
||||
selection,
|
||||
effectiveAnim,
|
||||
dragProps,
|
||||
ct,
|
||||
ts,
|
||||
td,
|
||||
callbacks,
|
||||
restoreOffset,
|
||||
);
|
||||
} else {
|
||||
await commitKeyframedPosition(selection, effectiveAnim, dragProps, callbacks, restoreOffset);
|
||||
}
|
||||
} else if (anim.method === "from" || anim.method === "fromTo") {
|
||||
const ct = usePlayerStore.getState().currentTime;
|
||||
const ts = resolveTweenStart(anim);
|
||||
const td = resolveTweenDuration(anim);
|
||||
const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01);
|
||||
const dragProps: Record<string, number> = { x: newX, y: newY };
|
||||
|
||||
if (outsideRange && ts !== null) {
|
||||
// Split the original from() tween into property groups first.
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{ type: "split-into-property-groups", animationId: anim.id },
|
||||
{ label: "Split from() for drag", skipReload: true },
|
||||
);
|
||||
|
||||
const allAnims = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : [];
|
||||
const existingPosAnim = allAnims.find(
|
||||
(a) => a.propertyGroup === "position" && a.targetSelector === anim.targetSelector,
|
||||
);
|
||||
|
||||
if (existingPosAnim?.keyframes) {
|
||||
// Extend the existing position tween
|
||||
const posTs = resolveTweenStart(existingPosAnim);
|
||||
const posTd = resolveTweenDuration(existingPosAnim);
|
||||
if (posTs !== null) {
|
||||
await extendTweenAndAddKeyframe(
|
||||
selection,
|
||||
existingPosAnim,
|
||||
{ x: newX, y: newY },
|
||||
ct,
|
||||
posTs,
|
||||
posTd,
|
||||
callbacks,
|
||||
restoreOffset,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// No existing position tween — create one
|
||||
const newStart = Math.min(ct, ts);
|
||||
const newEnd = Math.max(ct, ts + td);
|
||||
const newDuration = Math.max(0.01, newEnd - newStart);
|
||||
const dragBefore = ct < ts;
|
||||
const origStartPct = Math.round(((ts - newStart) / newDuration) * 1000) / 10;
|
||||
const origEndPct = Math.round(((ts + td - newStart) / newDuration) * 1000) / 10;
|
||||
|
||||
const keyframes: Array<{ percentage: number; properties: Record<string, number | string> }> =
|
||||
[];
|
||||
if (dragBefore) {
|
||||
keyframes.push({ percentage: 0, properties: { x: newX, y: newY } });
|
||||
if (origStartPct > 0.5 && origStartPct < 99.5) {
|
||||
keyframes.push({ percentage: origStartPct, properties: { x: 0, y: 0 } });
|
||||
}
|
||||
keyframes.push({ percentage: 100, properties: { x: 0, y: 0 } });
|
||||
} else {
|
||||
keyframes.push({ percentage: 0, properties: { x: 0, y: 0 } });
|
||||
if (origEndPct > 0.5 && origEndPct < 99.5) {
|
||||
keyframes.push({ percentage: origEndPct, properties: { x: 0, y: 0 } });
|
||||
}
|
||||
keyframes.push({ percentage: 100, properties: { x: newX, y: newY } });
|
||||
}
|
||||
keyframes.sort((a, b) => a.percentage - b.percentage);
|
||||
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "add-with-keyframes",
|
||||
targetSelector: anim.targetSelector,
|
||||
position: roundTo3(newStart),
|
||||
duration: roundTo3(newDuration),
|
||||
keyframes,
|
||||
},
|
||||
{ label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset },
|
||||
);
|
||||
} else {
|
||||
// Inside tween range: convert then add keyframe at current time
|
||||
const coalesceKey = `gsap:convert-drag:${anim.id}`;
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "convert-to-keyframes",
|
||||
animationId: anim.id,
|
||||
},
|
||||
{ label: "Convert from() for drag", skipReload: true, coalesceKey },
|
||||
);
|
||||
const pct = computeCurrentPercentage(selection, anim);
|
||||
await callbacks.commitMutation(
|
||||
selection,
|
||||
{
|
||||
type: "add-keyframe",
|
||||
animationId: anim.id,
|
||||
percentage: pct,
|
||||
properties: dragProps,
|
||||
},
|
||||
{
|
||||
label: `Move layer (keyframe ${pct}%)`,
|
||||
softReload: true,
|
||||
beforeReload: restoreOffset,
|
||||
coalesceKey,
|
||||
},
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await commitFlatViaKeyframes(
|
||||
selection,
|
||||
anim,
|
||||
{ x: newX, y: newY },
|
||||
callbacks,
|
||||
restoreOffset,
|
||||
iframe,
|
||||
selector,
|
||||
);
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user