From 95029e083e5066a62de5420861cf4a859c1ce914 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 12 Jun 2026 00:19:13 -0400 Subject: [PATCH] fix(studio): per-property-group intercept routing + drag/resize fixes (#1356) * fix(core): per-property-group keyframe foundations Add PropertyGroupName type system (position/scale/size/rotation/visual/other), PROPERTY_GROUPS constant, classifyPropertyGroup/classifyTweenPropertyGroup functions. Parser generates group-aware animation IDs, resolves position strings (+=, -=, <, >), uses numeric matching with 2% tolerance, and preserves IDs across all mutations. * fix(core): add split-into-property-groups and replace-with-keyframes mutations Server-side mutations for atomic property-group splitting and keyframe replacement. Client commitMutation returns early on changed:false instead of throwing. * fix(studio): per-property-group intercept routing + drag/resize fixes Rewire GSAP runtime bridge for property-group routing: drag sends only {x,y} to position group, resize routes to scale group via data-hf-studio-original-width, rotation routes to rotation group. Add resolveGroupTween helper, from-extend with split-first-then-position-only pattern, autoKeyframeEnabled guards, GSAP base + delta fix in drag draft, cancel-restores-GSAP-x/y from data attrs. --- .../src/components/editor/manualEditsDom.ts | 73 ++--- .../components/editor/manualEditsSnapshot.ts | 38 +-- .../src/components/editor/manualEditsTypes.ts | 8 - packages/studio/src/hooks/gsapDragCommit.ts | 177 +++++++--- .../studio/src/hooks/gsapRuntimeBridge.ts | 306 +++++++++++++++--- .../studio/src/hooks/gsapRuntimeReaders.ts | 18 +- .../src/hooks/useAnimatedPropertyCommit.ts | 16 +- .../studio/src/hooks/useDomEditSession.ts | 20 +- .../studio/src/hooks/useEnableKeyframes.ts | 4 +- .../studio/src/player/store/playerStore.ts | 12 + .../src/utils/globalTimeCompiler.test.ts | 4 +- .../studio/src/utils/globalTimeCompiler.ts | 3 +- 12 files changed, 503 insertions(+), 176 deletions(-) diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 647b125e0..46f599b88 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -32,7 +32,7 @@ import { } from "./manualEditsTypes"; import { roundRotationAngle } from "./manualEditsParsing"; import { applyStudioMotionFromDom } from "./studioMotion"; -import { gsapAnimatesProperty, gsapAnimatesTransform } from "./gsapAnimatesProperty"; +import { gsapAnimatesProperty } from "./gsapAnimatesProperty"; /* ── Gesture tracking ─────────────────────────────────────────────── */ let studioManualEditGestureId = 0; @@ -223,7 +223,6 @@ function isIdentityAfterTranslateStrip(m: DOMMatrix): boolean { return m.is2D && m.a === 1 && m.b === 0 && m.c === 0 && m.d === 1; } -// fallow-ignore-next-line complexity function stripGsapTranslateFromTransform(element: HTMLElement): void { if (element.hasAttribute(STUDIO_MANUAL_EDIT_GESTURE_ATTR)) return; const transform = element.style.getPropertyValue("transform"); @@ -258,18 +257,6 @@ export function applyStudioPathOffset( ): void { promoteInlineForTransform(element); writeStudioPathOffsetVars(element, offset, { updateBase: options.updateBase ?? true }); - if (gsapAnimatesTransform(element)) { - // GSAP folded the CSS translate into its transform cache at init and owns - // style.transform from then on — it zeroes the translate longhand exactly - // once (at fold time) and never re-reads it. Writing translate here would - // double-apply the offset on top of the baked transform. Keep translate - // neutral in the live DOM and push the offset into GSAP's cache instead; - // the var() expression is persisted to the source file by the patch - // builder, where a reload re-folds it. - element.style.setProperty("translate", "none"); - syncGsapOwnedTransformPosition(element); - return; - } element.style.setProperty( "translate", composeTranslateValue( @@ -281,24 +268,6 @@ export function applyStudioPathOffset( stripGsapTranslateFromTransform(element); } -/** - * After committing a new path offset on an element whose transform GSAP owns, - * GSAP's internal cache still holds the pre-drag baked translate — the next - * seek re-renders from that cache and snaps the element back. Push the new - * offset into GSAP so live scrubbing matches what was persisted. (A page - * reload re-initializes GSAP from the persisted CSS translate, so this is - * only needed for the live session.) - */ -function syncGsapOwnedTransformPosition(element: HTMLElement): void { - if (!gsapAnimatesTransform(element)) return; - const win = element.ownerDocument.defaultView as - | (Window & { gsap?: { set: (el: Element, vars: Record) => void } }) - | null; - if (!win?.gsap?.set) return; - const { x, y } = readStudioPathOffset(element); - win.gsap.set(element, { x, y }); -} - export function applyStudioPathOffsetDraft( element: HTMLElement, offset: { x: number; y: number }, @@ -306,16 +275,36 @@ export function applyStudioPathOffsetDraft( promoteInlineForTransform(element); writeStudioPathOffsetVars(element, offset, { updateBase: false }); - const isGsapAnimated = gsapAnimatesTransform(element); + const isGsapAnimated = gsapAnimatesProperty(element, "x", "y"); if (isGsapAnimated) { - // GSAP owns style.transform (see applyStudioPathOffset): position via - // gsap.set while the timeline is paused. Set translate:none explicitly to - // prevent double-counting with the baked transform. element.style.setProperty("translate", "none"); const win = element.ownerDocument.defaultView as - | (Window & { gsap?: { set: (el: Element, vars: Record) => void } }) + | (Window & { + gsap?: { + set: (el: Element, vars: Record) => void; + getProperty: (el: Element, prop: string) => number; + }; + }) | null; - win?.gsap?.set(element, { x: offset.x, y: offset.y }); + if (win?.gsap) { + const baseX = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-x") ?? ""); + const baseY = Number.parseFloat(element.getAttribute("data-hf-drag-gsap-base-y") ?? ""); + const origX = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-x") ?? ""); + const origY = Number.parseFloat(element.getAttribute("data-hf-drag-initial-offset-y") ?? ""); + const gsapBaseX = Number.isFinite(baseX) + ? baseX + : (win.gsap.getProperty(element, "x") as number); + const gsapBaseY = Number.isFinite(baseY) + ? baseY + : (win.gsap.getProperty(element, "y") as number); + if (!Number.isFinite(baseX)) + element.setAttribute("data-hf-drag-gsap-base-x", String(gsapBaseX)); + if (!Number.isFinite(baseY)) + element.setAttribute("data-hf-drag-gsap-base-y", String(gsapBaseY)); + const deltaX = offset.x - (Number.isFinite(origX) ? origX : 0); + const deltaY = offset.y - (Number.isFinite(origY) ? origY : 0); + win.gsap.set(element, { x: gsapBaseX + deltaX, y: gsapBaseY + deltaY }); + } } else { // Non-GSAP elements: use CSS translate as before. element.style.setProperty( @@ -551,14 +540,10 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] { function reapplyPathOffsets(doc: Document): void { for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) { - // Skip elements where GSAP owns the transform stack — GSAP bakes the - // CSS translate into its transform and sets translate: none every tick - // when it tweens ANY transform property (x/y, scale, rotation, ...). - // Stripping/restoring would oscillate against GSAP's rendering and - // double-apply the offset. - if (gsapAnimatesTransform(el)) continue; + const gsapSkip = gsapAnimatesProperty(el, "x", "y"); const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP); const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP); + if (gsapSkip) continue; if (x || y) { applyStudioPathOffset( el, diff --git a/packages/studio/src/components/editor/manualEditsSnapshot.ts b/packages/studio/src/components/editor/manualEditsSnapshot.ts index df9569ab4..afc8aa30e 100644 --- a/packages/studio/src/components/editor/manualEditsSnapshot.ts +++ b/packages/studio/src/components/editor/manualEditsSnapshot.ts @@ -4,7 +4,6 @@ import { styleUsesStudioRotation, restoreInlineDisplay, } from "./manualEditsDom"; -import { gsapAnimatesTransform } from "./gsapAnimatesProperty"; import { STUDIO_OFFSET_X_PROP, STUDIO_OFFSET_Y_PROP, @@ -88,23 +87,7 @@ export function captureStudioRotation(element: HTMLElement): StudioRotationSnaps }; } -type GsapWindow = Window & { - gsap?: { - getProperty?: (el: Element, prop: string) => number | string; - set?: (el: Element, vars: Record) => void; - }; -}; - export function captureStudioPathOffset(element: HTMLElement): StudioPathOffsetSnapshot { - let gsapX: number | null = null; - let gsapY: number | null = null; - if (gsapAnimatesTransform(element)) { - const win = element.ownerDocument.defaultView as GsapWindow | null; - if (win?.gsap?.getProperty) { - gsapX = Number(win.gsap.getProperty(element, "x")) || 0; - gsapY = Number(win.gsap.getProperty(element, "y")) || 0; - } - } return { translate: element.style.getPropertyValue("translate"), x: element.style.getPropertyValue(STUDIO_OFFSET_X_PROP), @@ -112,8 +95,6 @@ export function captureStudioPathOffset(element: HTMLElement): StudioPathOffsetS marker: element.getAttribute(STUDIO_PATH_OFFSET_ATTR), originalTranslate: element.getAttribute(STUDIO_ORIGINAL_TRANSLATE_ATTR), originalInlineTranslate: element.getAttribute(STUDIO_ORIGINAL_INLINE_TRANSLATE_ATTR), - gsapX, - gsapY, }; } @@ -203,11 +184,20 @@ export function restoreStudioPathOffset( previous.originalInlineTranslate, ); - // Draft positioning on GSAP-owned elements goes through gsap.set, which - // mutates GSAP's transform cache — restore it alongside the inline styles. - if (previous.gsapX != null || previous.gsapY != null) { - const win = element.ownerDocument.defaultView as GsapWindow | null; - win?.gsap?.set?.(element, { x: previous.gsapX ?? 0, y: previous.gsapY ?? 0 }); + // Restore GSAP x/y if a draft was applied via gsap.set during drag + const baseX = element.getAttribute("data-hf-drag-gsap-base-x"); + const baseY = element.getAttribute("data-hf-drag-gsap-base-y"); + if (baseX != null || baseY != null) { + const win = element.ownerDocument.defaultView as + | (Window & { gsap?: { set: (el: Element, vars: Record) => void } }) + | null; + if (win?.gsap) { + const x = Number.parseFloat(baseX ?? "0") || 0; + const y = Number.parseFloat(baseY ?? "0") || 0; + win.gsap.set(element, { x, y }); + } + element.removeAttribute("data-hf-drag-gsap-base-x"); + element.removeAttribute("data-hf-drag-gsap-base-y"); } } diff --git a/packages/studio/src/components/editor/manualEditsTypes.ts b/packages/studio/src/components/editor/manualEditsTypes.ts index 6ef53aaa1..f54071182 100644 --- a/packages/studio/src/components/editor/manualEditsTypes.ts +++ b/packages/studio/src/components/editor/manualEditsTypes.ts @@ -101,12 +101,4 @@ export interface StudioPathOffsetSnapshot { marker: string | null; originalTranslate: string | null; originalInlineTranslate: string | null; - /** - * GSAP's cached x/y at capture time, for elements whose transform GSAP - * owns. Draft positioning mutates GSAP's cache (gsap.set), which inline - * style restoration alone cannot undo. Null when GSAP does not own the - * element's transform. - */ - gsapX: number | null; - gsapY: number | null; } diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index 797376408..6e23936e5 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -11,8 +11,6 @@ import { resolveTweenStart, resolveTweenDuration, } from "../utils/globalTimeCompiler"; -import { readAllAnimatedProperties } from "./gsapRuntimeReaders"; - export interface GsapDragCommitCallbacks { commitMutation: ( selection: DomEditSelection, @@ -114,7 +112,6 @@ async function extendTweenAndAddKeyframe( 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 }> = []; @@ -126,20 +123,15 @@ async function extendTweenAndAddKeyframe( 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: "delete", animationId: anim.id }, - { label: "Extend tween range", skipReload: true }, - ); - - const selector = anim.targetSelector; await callbacks.commitMutation( selection, { - type: "add-with-keyframes", - targetSelector: selector, + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: anim.targetSelector, position: Math.round(newStart * 1000) / 1000, duration: Math.round(newDuration * 1000) / 1000, keyframes: remappedKfs, @@ -156,8 +148,9 @@ async function commitKeyframedPosition( callbacks: GsapDragCommitCallbacks, beforeReload?: () => void, ): Promise { - const pct = computeCurrentPercentage(selection, anim); - + const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); + const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); + if (activeKeyframePct != null) setActiveKeyframePct(null); await callbacks.commitMutation( selection, { @@ -182,10 +175,11 @@ async function commitFlatViaKeyframes( callbacks: GsapDragCommitCallbacks, beforeReload?: () => void, ): Promise { + const coalesceKey = `gsap:convert-drag:${anim.id}`; await callbacks.commitMutation( selection, { type: "convert-to-keyframes", animationId: anim.id }, - { label: "Convert to keyframes for drag", skipReload: true }, + { label: "Convert to keyframes for drag", skipReload: true, coalesceKey }, ); const pct = computeCurrentPercentage(selection, anim); @@ -198,7 +192,7 @@ async function commitFlatViaKeyframes( percentage: pct, properties, }, - { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, + { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload, coalesceKey }, ); } @@ -243,19 +237,20 @@ export async function commitGsapPositionFromDrag( el.removeAttribute("data-hf-drag-initial-offset-y"); }; + const ct = usePlayerStore.getState().currentTime; if (anim.keyframes) { const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); const effectiveAnim = newId ? { ...anim, id: newId } : anim; - const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); + const dragProps: Record = { x: newX, y: newY }; - const ct = usePlayerStore.getState().currentTime; const ts = resolveTweenStart(effectiveAnim); const td = resolveTweenDuration(effectiveAnim); - if (ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01)) { + const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); + if (outsideRange) { await extendTweenAndAddKeyframe( selection, effectiveAnim, - { ...runtimeProps, x: newX, y: newY }, + dragProps, ct, ts, td, @@ -263,32 +258,126 @@ export async function commitGsapPositionFromDrag( restoreOffset, ); } else { - await commitKeyframedPosition( - selection, - effectiveAnim, - { ...runtimeProps, x: newX, y: newY }, - callbacks, - restoreOffset, - ); + await commitKeyframedPosition(selection, effectiveAnim, dragProps, callbacks, restoreOffset); } } else if (anim.method === "from" || anim.method === "fromTo") { - await callbacks.commitMutation( - selection, - { - type: "convert-to-keyframes", - animationId: anim.id, - resolvedFromValues: { x: newX, y: newY }, - }, - { label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset }, - ); + 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 = { 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 }, + ); + + // Check if a position-group tween already exists (e.g. from gesture recording). + // If so, extend it instead of creating a duplicate. + const allAnims = await (async () => { + const pid = selection.sourceFile || "index.html"; + try { + const r = await fetch( + `/api/projects/${encodeURIComponent(window.location.hash.match(/project\/([^?/]+)/)?.[1] ?? "")}/gsap-animations/${encodeURIComponent(pid)}`, + ); + if (!r.ok) return []; + const parsed = await r.json(); + return (parsed?.animations ?? []) as GsapAnimation[]; + } catch { + return []; + } + })(); + 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 }> = + []; + 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: Math.round(newStart * 1000) / 1000, + duration: Math.round(newDuration * 1000) / 1000, + 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 { - const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); - await commitFlatViaKeyframes( - selection, - anim, - { ...runtimeProps, x: newX, y: newY }, - callbacks, - restoreOffset, - ); + await commitFlatViaKeyframes(selection, anim, { x: newX, y: newY }, callbacks, restoreOffset); } } diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index f14d68ed1..1bdfefb21 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -8,7 +8,7 @@ * absolute positions back into the GSAP script, regardless of tween type, * easing, or seek position. */ -import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { GsapAnimation, PropertyGroupName } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; @@ -18,6 +18,7 @@ import { computeCurrentPercentage, materializeIfDynamic, } from "./gsapDragCommit"; +import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; import type { GsapDragCommitCallbacks } from "./gsapDragCommit"; // ── Runtime reads ────────────────────────────────────────────────────────── @@ -87,7 +88,7 @@ function findGsapPositionAnimation( if (a.keyframes) score += 5; if (selector && a.targetSelector === selector) score += 8; else if (a.targetSelector.includes(",")) score -= 5; - const pos = typeof a.position === "number" ? a.position : 0; + const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0); const dur = a.duration ?? 0; if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 4; return { anim: a, score }; @@ -104,6 +105,74 @@ function selectorForSelection(selection: DomEditSelection): string | null { return null; } +// ── Property-group tween resolution ─────────────────────────────────────── + +/** + * Find the tween for a given property group, splitting a legacy mixed tween + * if necessary. Returns the resolved animation or null if none exists. + * + * Resolution order: + * 1. Tween already tagged with `propertyGroup === group` + * 2. Legacy mixed tween (`!propertyGroup`) → split via server mutation, + * re-fetch, then return the group tween + * 3. null — caller must handle the missing-tween case + */ +async function resolveGroupTween( + group: PropertyGroupName, + animations: GsapAnimation[], + selection: DomEditSelection, + commitMutation: GsapDragCommitCallbacks["commitMutation"], + fetchFallbackAnimations?: () => Promise, +): Promise<{ anim: GsapAnimation; animations: GsapAnimation[] } | null> { + // 1. Already-split group tween — prefer the one with the most keyframes + // to avoid targeting a stub when a gesture-recorded tween also exists. + const groupAnims = animations.filter((a) => a.propertyGroup === group); + const groupAnim = + groupAnims.length > 1 + ? groupAnims.sort( + (a, b) => (b.keyframes?.keyframes.length ?? 0) - (a.keyframes?.keyframes.length ?? 0), + )[0] + : (groupAnims[0] ?? null); + if (groupAnim) return { anim: groupAnim, animations }; + + // 2. Legacy mixed tween — split it, then re-fetch + const legacyMixed = animations.find((a) => !a.propertyGroup); + if (legacyMixed) { + await commitMutation( + selection, + { type: "split-into-property-groups", animationId: legacyMixed.id }, + { label: "Split mixed tween into property groups", skipReload: true }, + ); + if (fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + const freshGroupAnim = fresh.find((a) => a.propertyGroup === group); + if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh }; + } + } + + // 3. Try fallback fetch (no split needed, just wasn't in the initial list) + if (!legacyMixed && fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + const freshGroupAnim = fresh.find((a) => a.propertyGroup === group); + if (freshGroupAnim) return { anim: freshGroupAnim, animations: fresh }; + + // Fallback: legacy mixed in the fresh list + const freshLegacy = fresh.find((a) => !a.propertyGroup); + if (freshLegacy) { + await commitMutation( + selection, + { type: "split-into-property-groups", animationId: freshLegacy.id }, + { label: "Split mixed tween into property groups", skipReload: true }, + ); + const reFetched = await fetchFallbackAnimations(); + const reFetchedGroup = reFetched.find((a) => a.propertyGroup === group); + if (reFetchedGroup) return { anim: reFetchedGroup, animations: reFetched }; + } + } + + return null; +} + // ── High-level intercept ─────────────────────────────────────────────────── export type { GsapDragCommitCallbacks }; @@ -127,10 +196,24 @@ export async function tryGsapDragIntercept( const selector = selectorForSelection(selection); if (!selector) return false; - let posAnim = findGsapPositionAnimation(animations, selector); - if (!posAnim && fetchFallbackAnimations) { - const fresh = await fetchFallbackAnimations(); - posAnim = findGsapPositionAnimation(fresh, selector); + // Resolve the position-group tween, splitting legacy mixed tweens if needed. + const resolved = await resolveGroupTween( + "position", + animations, + selection, + commitMutation, + fetchFallbackAnimations, + ); + + // Fallback: use the legacy scoring heuristic for compositions that don't + // have group-tagged tweens at all (e.g. hand-written scripts). + let posAnim = resolved?.anim ?? null; + if (!posAnim) { + posAnim = findGsapPositionAnimation(animations, selector); + if (!posAnim && fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + posAnim = findGsapPositionAnimation(fresh, selector); + } } if (!posAnim) return false; @@ -151,6 +234,22 @@ export async function tryGsapDragIntercept( export { readGsapProperty, readAllAnimatedProperties }; +// ── Identity-prop synthesis ─────────────────────────────────────────────── + +const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]); + +/** Build identity (zero / one) values for each property in `source`. */ +function synthesizeIdentityProps( + source: Record, +): Record { + const id: Record = {}; + for (const [k, v] of Object.entries(source)) { + if (typeof v === "number") id[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0; + else id[k] = v; + } + return id; +} + // ── Resize intercept ────────────────────────────────────────────────────── export async function tryGsapResizeIntercept( @@ -161,46 +260,155 @@ export async function tryGsapResizeIntercept( commitMutation: GsapDragCommitCallbacks["commitMutation"], fetchFallbackAnimations?: () => Promise, ): Promise { - let anim = animations.find( - (a) => "width" in a.properties || "height" in a.properties || a.keyframes, + // If the element already has a scale-group tween, resize should modify scale + // (the user is resizing something whose visual size is driven by scale). + // Otherwise, use the size group (width/height). + const hasScaleGroup = animations.some((a) => a.propertyGroup === "scale"); + const resizeGroup: PropertyGroupName = hasScaleGroup ? "scale" : "size"; + const resolved = await resolveGroupTween( + resizeGroup, + animations, + selection, + commitMutation, + fetchFallbackAnimations, ); - if (!anim && fetchFallbackAnimations) { - const fresh = await fetchFallbackAnimations(); - anim = fresh.find((a) => "width" in a.properties || "height" in a.properties || a.keyframes); - } - if (!anim) return false; - const pct = computeCurrentPercentage(selection, anim); - - if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) { - const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); - if (newId) anim = { ...anim, id: newId }; - } else if (!anim.keyframes) { + let anim = resolved?.anim ?? null; + if (!anim) { + // No size-group tween exists — create one. Use the element's timing + // from any existing animation, or fall back to element data attributes. + const refAnim = animations[0]; + const elStart = + refAnim?.resolvedStart ?? (Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0); + const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "5") || 5; + const ct = usePlayerStore.getState().currentTime; + const pct = elDuration > 0 ? Math.round(((ct - elStart) / elDuration) * 1000) / 10 : 0; + const sel = selectorForSelection(selection); + if (!sel) return false; await commitMutation( selection, - { type: "convert-to-keyframes", animationId: anim.id }, - { label: "Convert to keyframes for resize", skipReload: true }, + { + type: "add-with-keyframes", + targetSelector: sel, + position: Math.round(elStart * 1000) / 1000, + duration: Math.round(elDuration * 1000) / 1000, + keyframes: [ + { + percentage: Math.max(0, Math.min(100, pct)), + properties: { width: Math.round(size.width), height: Math.round(size.height) }, + }, + ], + }, + { label: "Resize (new size keyframe)", softReload: true }, ); + return true; } + const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); + const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); + if (activeKeyframePct != null) setActiveKeyframePct(null); + const coalesceKey = `gsap:resize:${anim.id}`; + const selector = selectorForSelection(selection); const runtimeProps = selector ? readAllAnimatedProperties(iframe, selector, anim) : {}; - const backfillDefaults: Record = { ...runtimeProps }; - if (!("width" in runtimeProps)) { - const cssW = readGsapProperty(iframe, selector, "width"); - backfillDefaults.width = cssW ?? Math.round(size.width); + let resizeProps: Record; + if (resizeGroup === "scale") { + const el = iframe?.contentDocument?.querySelector(selector ?? "") as HTMLElement | null; + // The resize draft modifies el.style.width, so read the ORIGINAL width + // saved by the draft system before it ran. + const origW = Number.parseFloat(el?.getAttribute("data-hf-studio-original-width") ?? ""); + const cssW = Number.isFinite(origW) && origW > 0 ? origW : 200; + const newScale = Math.round((size.width / cssW) * 1000) / 1000; + resizeProps = { scale: newScale }; + } else { + resizeProps = { + width: Math.round(size.width), + height: Math.round(size.height), + }; } - if (!("height" in runtimeProps)) { - const cssH = readGsapProperty(iframe, selector, "height"); - backfillDefaults.height = cssH ?? Math.round(size.height); + 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); // Convert flat tweens to keyframes only for in-range resizes. + // Outside-range uses the extend path which handles everything atomically. + if (!outsideRange) { + if (anim.hasUnresolvedKeyframes || anim.hasUnresolvedSelector) { + const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); + if (newId) anim = { ...anim, id: newId }; + } else if (!anim.keyframes) { + const resolvedFromValues = selector + ? readAllAnimatedProperties(iframe, selector, anim) + : undefined; + await commitMutation( + selection, + { type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues }, + { label: "Convert to keyframes for resize", skipReload: true, coalesceKey }, + ); + } } - const properties = { - ...runtimeProps, - width: Math.round(size.width), - height: Math.round(size.height), - }; + if (outsideRange && ts !== null) { + // For flat tweens, synthesize the keyframes from the tween's properties + const kfs = + anim.keyframes?.keyframes ?? + (() => { + const fromProps = + anim.method === "from" || anim.method === "fromTo" + ? { ...anim.properties } + : synthesizeIdentityProps(anim.properties); + const toProps = + anim.method === "from" + ? synthesizeIdentityProps(anim.properties) + : { ...anim.properties }; + return [ + { percentage: 0, properties: fromProps }, + { percentage: 100, properties: toProps }, + ]; + })(); + const newStart = Math.min(ct, ts); + const newEnd = Math.max(ct, ts + td); + const newDuration = Math.max(0.01, newEnd - newStart); + const existingKfs = kfs; + const remapped: Array<{ percentage: number; properties: Record }> = []; + for (const kf of existingKfs) { + const absTime = ts + (kf.percentage / 100) * td; + const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10; + const props = { ...kf.properties }; + // Only backfill properties that the animation already had (x, y, scale). + // Don't backfill width/height — they should only appear on the resize keyframe. + for (const k of Object.keys(resizeProps)) { + if (k in props) continue; + if (k === "width" || k === "height") continue; + props[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0; + } + remapped.push({ percentage: newPct, properties: props }); + } + const targetPct = Math.round(((ct - newStart) / newDuration) * 1000) / 10; + remapped.push({ percentage: targetPct, properties: resizeProps }); + remapped.sort((a, b) => a.percentage - b.percentage); + + await commitMutation( + selection, + { + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: anim.targetSelector, + position: Math.round(newStart * 1000) / 1000, + duration: Math.round(newDuration * 1000) / 1000, + keyframes: remapped, + }, + { label: `Resize (extended to ${ct.toFixed(2)}s)`, softReload: true, coalesceKey }, + ); + return true; + } + + const SIZE_PROPS = new Set(["width", "height"]); + const backfillDefaults: Record = {}; + for (const k of Object.keys(runtimeProps)) { + if (SIZE_PROPS.has(k)) continue; + backfillDefaults[k] = IDENTITY_ONE_PROPS.has(k) ? 1 : 0; + } await commitMutation( selection, @@ -208,10 +416,10 @@ export async function tryGsapResizeIntercept( type: "add-keyframe", animationId: anim.id, percentage: pct, - properties, + properties: resizeProps, backfillDefaults, }, - { label: `Resize (keyframe ${pct}%)`, softReload: true }, + { label: `Resize (keyframe ${pct}%)`, softReload: true, coalesceKey }, ); return true; } @@ -226,10 +434,23 @@ export async function tryGsapRotationIntercept( commitMutation: GsapDragCommitCallbacks["commitMutation"], fetchFallbackAnimations?: () => Promise, ): Promise { - let anim = animations.find((a) => "rotation" in a.properties || a.keyframes); - if (!anim && fetchFallbackAnimations) { - const fresh = await fetchFallbackAnimations(); - anim = fresh.find((a) => "rotation" in a.properties || a.keyframes); + // Resolve the rotation-group tween, splitting legacy mixed tweens if needed. + const resolved = await resolveGroupTween( + "rotation", + animations, + selection, + commitMutation, + fetchFallbackAnimations, + ); + + // Fallback: legacy heuristic for hand-written scripts + let anim = resolved?.anim ?? null; + if (!anim) { + anim = animations.find((a) => "rotation" in a.properties || a.keyframes) ?? null; + if (!anim && fetchFallbackAnimations) { + const fresh = await fetchFallbackAnimations(); + anim = fresh.find((a) => "rotation" in a.properties || a.keyframes) ?? null; + } } if (!anim) return false; @@ -261,14 +482,17 @@ export async function tryGsapRotationIntercept( const newId = await materializeIfDynamic(anim, iframe, commitMutation, selection); if (newId) anim = { ...anim, id: newId }; } else if (!anim.keyframes) { + const resolvedFromValues = selector + ? readAllAnimatedProperties(iframe, selector, anim, "rotation") + : undefined; await commitMutation( selection, - { type: "convert-to-keyframes", animationId: anim.id }, + { type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues }, { label: "Convert to keyframes for rotation", skipReload: true }, ); } - const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); + const runtimeProps = readAllAnimatedProperties(iframe, selector, anim, "rotation"); const backfillDefaults: Record = { ...runtimeProps }; if (!("rotation" in runtimeProps)) { diff --git a/packages/studio/src/hooks/gsapRuntimeReaders.ts b/packages/studio/src/hooks/gsapRuntimeReaders.ts index 1f2898716..0cac04524 100644 --- a/packages/studio/src/hooks/gsapRuntimeReaders.ts +++ b/packages/studio/src/hooks/gsapRuntimeReaders.ts @@ -2,6 +2,7 @@ * Low-level GSAP runtime property readers shared by gsapRuntimeBridge and gsapDragCommit. */ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { classifyPropertyGroup, type PropertyGroupName } from "@hyperframes/core/gsap-parser"; interface IframeGsap { getProperty: (el: Element, prop: string) => number; @@ -19,7 +20,8 @@ export function readGsapProperty( const el = iframe.contentDocument?.querySelector(selector); if (!el) return null; const val = Number(gsap.getProperty(el, prop)); - return Number.isFinite(val) ? Math.round(val) : null; + if (!Number.isFinite(val)) return null; + return POSITION_PROPS.has(prop) ? Math.round(val) : Math.round(val * 1000) / 1000; } catch { return null; } @@ -51,6 +53,7 @@ export function readAllAnimatedProperties( iframe: HTMLIFrameElement | null, selector: string, anim: GsapAnimation, + group?: PropertyGroupName, ): Record { const result: Record = {}; if (!iframe?.contentWindow) return result; @@ -81,6 +84,13 @@ export function readAllAnimatedProperties( for (const p of Object.keys(anim.properties)) propKeys.add(p); } + // When a group filter is specified, only keep properties belonging to that group. + if (group) { + for (const p of propKeys) { + if (classifyPropertyGroup(p) !== group) propKeys.delete(p); + } + } + for (const prop of propKeys) { const val = Number(gsap.getProperty(el, prop)); if (Number.isFinite(val)) { @@ -147,9 +157,13 @@ export function readAllAnimatedProperties( sepia: 0, invert: 0, }; + // Collect all properties that ANY tween on this element explicitly targets. + // Only capture baseline values for these — GSAP reports non-default values + // (scaleZ=0, brightness=0) for untouched properties, polluting keyframes. + const allTweenedProps = new Set([...propKeys, ...otherTweenProps]); for (const [prop, defaultVal] of Object.entries(UNIVERSAL_BASELINE)) { if (prop in result) continue; - if (otherTweenProps.has(prop)) continue; + if (!allTweenedProps.has(prop)) continue; const val = Number(gsap.getProperty(el, prop)); if (Number.isFinite(val) && Math.round(val * 1000) !== Math.round(defaultVal * 1000)) { result[prop] = Math.round(val * 1000) / 1000; diff --git a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts index a25064e5e..75a3d42a0 100644 --- a/packages/studio/src/hooks/useAnimatedPropertyCommit.ts +++ b/packages/studio/src/hooks/useAnimatedPropertyCommit.ts @@ -8,6 +8,7 @@ */ import { useCallback } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeBridge"; @@ -38,7 +39,7 @@ interface CommitAnimatedPropertyDeps { function computePercentage(selection: DomEditSelection, anim?: GsapAnimation): number { const currentTime = usePlayerStore.getState().currentTime; - const tweenPos = typeof anim?.position === "number" ? anim.position : 0; + const tweenPos = anim?.resolvedStart ?? (typeof anim?.position === "number" ? anim.position : 0); const tweenDur = anim?.duration ?? 0; if (tweenDur > 0) { return Math.max( @@ -56,18 +57,19 @@ function computePercentage(selection: DomEditSelection, anim?: GsapAnimation): n function pickBestAnimation( animations: GsapAnimation[], selector: string | null, + property?: string, ): GsapAnimation | undefined { if (animations.length <= 1) return animations[0]; const currentTime = usePlayerStore.getState().currentTime; + const targetGroup = property ? classifyPropertyGroup(property) : undefined; const scored = animations.map((a) => { let score = 0; + if (targetGroup && a.propertyGroup === targetGroup) score += 20; if (a.keyframes) score += 10; - // Prefer single-element selectors over comma-separated groups if (selector && a.targetSelector === selector) score += 5; else if (a.targetSelector.includes(",")) score -= 3; - // Prefer tweens active at the current time - const pos = typeof a.position === "number" ? a.position : 0; + const pos = a.resolvedStart ?? (typeof a.position === "number" ? a.position : 0); const dur = a.duration ?? 0; if (currentTime >= pos - 0.05 && currentTime <= pos + dur + 0.05) score += 8; return { anim: a, score }; @@ -102,7 +104,11 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) { const iframe = previewIframeRef.current; const selector = selectorFor(selection); - let anim: GsapAnimation | undefined = pickBestAnimation(selectedGsapAnimations, selector); + let anim: GsapAnimation | undefined = pickBestAnimation( + selectedGsapAnimations, + selector, + property, + ); // Case 3: No animation — create one first if (!anim) { diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index a191452d9..febdb7a46 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -2,8 +2,8 @@ import { useCallback, useEffect, useRef } from "react"; import type { TimelineElement } from "../player"; import { usePlayerStore } from "../player"; import { - STUDIO_GSAP_PANEL_ENABLED, STUDIO_GSAP_DRAG_INTERCEPT_ENABLED, + STUDIO_GSAP_PANEL_ENABLED, } from "../components/editor/manualEditingAvailability"; import { type DomEditSelection } from "../components/editor/domEditing"; import { useDomEditPreviewSync } from "./useDomEditPreviewSync"; @@ -329,7 +329,11 @@ export function useDomEditSession({ // GSAP-aware: intercept offset/resize/rotation to commit via script mutation when animated. const handleGsapAwarePathOffsetCommit = useCallback( async (selection: DomEditSelection, next: { x: number; y: number }) => { - if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) { + if ( + STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && + gsapCommitMutation && + usePlayerStore.getState().autoKeyframeEnabled + ) { const handled = await tryGsapDragIntercept( selection, next, @@ -375,7 +379,11 @@ export function useDomEditSession({ const handleGsapAwareBoxSizeCommit = useCallback( async (selection: DomEditSelection, next: { width: number; height: number }) => { - if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) { + if ( + STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && + gsapCommitMutation && + usePlayerStore.getState().autoKeyframeEnabled + ) { const handled = await tryGsapResizeIntercept( selection, next, @@ -399,7 +407,11 @@ export function useDomEditSession({ const handleGsapAwareRotationCommit = useCallback( async (selection: DomEditSelection, next: { angle: number }) => { - if (gsapCommitMutation && STUDIO_GSAP_DRAG_INTERCEPT_ENABLED) { + if ( + STUDIO_GSAP_DRAG_INTERCEPT_ENABLED && + gsapCommitMutation && + usePlayerStore.getState().autoKeyframeEnabled + ) { const handled = await tryGsapRotationIntercept( selection, next.angle, diff --git a/packages/studio/src/hooks/useEnableKeyframes.ts b/packages/studio/src/hooks/useEnableKeyframes.ts index 3978ff871..ca6ea8e19 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.ts @@ -52,10 +52,12 @@ function readElementPosition( const element = sel.element; if (!element?.isConnected || !gsap?.getProperty) return result; + const POSITION_PROPS = new Set(["x", "y", "xPercent", "yPercent"]); const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"]; for (const prop of props) { const val = Number(gsap.getProperty(element, prop)); - if (Number.isFinite(val)) result[prop] = Math.round(val); + if (!Number.isFinite(val)) continue; + result[prop] = POSITION_PROPS.has(prop) ? Math.round(val) : Math.round(val * 1000) / 1000; } return result; diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index 465f1d710..194eb592e 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -6,6 +6,10 @@ export interface KeyframeCacheEntry { format: string; keyframes: Array<{ percentage: number; + /** Original tween-relative percentage (server mutations need this, not the clip-relative `percentage`). */ + tweenPercentage?: number; + /** Which property group the source tween belongs to (position, scale, rotation, visual, etc.). */ + propertyGroup?: string; properties: Record; ease?: string; }>; @@ -74,6 +78,11 @@ interface PlayerState { toggleSelectedKeyframe: (key: string) => void; clearSelectedKeyframes: () => void; + /** Tween-relative percentage of the last-clicked keyframe diamond. Operations + * (drag, resize, rotate) target this instead of recomputing from playhead. */ + activeKeyframePct: number | null; + setActiveKeyframePct: (pct: number | null) => void; + /** Multi-select: additional selected elements beyond selectedElementId. */ selectedElementIds: Set; toggleSelectedElementId: (id: string) => void; @@ -170,6 +179,9 @@ export const usePlayerStore = create((set) => ({ }), clearSelectedKeyframes: () => set({ selectedKeyframes: new Set() }), + activeKeyframePct: null, + setActiveKeyframePct: (pct) => set({ activeKeyframePct: pct }), + keyframeClipboard: null, setKeyframeClipboard: (data) => set({ keyframeClipboard: data }), diff --git a/packages/studio/src/utils/globalTimeCompiler.test.ts b/packages/studio/src/utils/globalTimeCompiler.test.ts index db4095cbc..963968f09 100644 --- a/packages/studio/src/utils/globalTimeCompiler.test.ts +++ b/packages/studio/src/utils/globalTimeCompiler.test.ts @@ -103,8 +103,8 @@ describe("resolveTweenDuration", () => { expect(resolveTweenDuration(makeAnim({ duration: 2 }))).toBe(2); }); - test("missing duration defaults to 1", () => { - expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(1); + test("missing duration defaults to GSAP default (0.5)", () => { + expect(resolveTweenDuration(makeAnim({ duration: undefined }))).toBe(0.5); }); }); diff --git a/packages/studio/src/utils/globalTimeCompiler.ts b/packages/studio/src/utils/globalTimeCompiler.ts index 9abe6c83d..3f050f925 100644 --- a/packages/studio/src/utils/globalTimeCompiler.ts +++ b/packages/studio/src/utils/globalTimeCompiler.ts @@ -27,6 +27,7 @@ export function isTimeWithinTween( } export function resolveTweenStart(animation: GsapAnimation): number | null { + if (animation.resolvedStart != null) return animation.resolvedStart; if (typeof animation.position === "number") return animation.position; const parsed = Number.parseFloat(animation.position as string); if (!Number.isNaN(parsed)) return parsed; @@ -34,7 +35,7 @@ export function resolveTweenStart(animation: GsapAnimation): number | null { } export function resolveTweenDuration(animation: GsapAnimation): number { - return animation.duration ?? 1; + return animation.duration ?? 0.5; } export function findTweenAtTime(