diff --git a/packages/studio/src/hooks/gsapDragCommit.test.ts b/packages/studio/src/hooks/gsapDragCommit.test.ts index 221fbebffa..d9f94c2bcd 100644 --- a/packages/studio/src/hooks/gsapDragCommit.test.ts +++ b/packages/studio/src/hooks/gsapDragCommit.test.ts @@ -14,6 +14,7 @@ import { type GsapDragCommitCallbacks, } from "./gsapDragCommit"; import { usePlayerStore } from "../player/store/playerStore"; +import { liveTween, previewWith } from "./gsapParsedTween.test-helpers"; // Minimal selection whose element has no drag-baseline attributes (origX/Y = 0). const selection = (): DomEditSelection => @@ -111,175 +112,86 @@ function recordingCallbacks(): { }; } -describe("commitGsapPositionFromDrag — flat tween", () => { - beforeEach(() => { - usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }); - }); +describe("commitGsapPositionFromDrag — at a selected keyframe or past the tween", () => { + beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null })); + const puck = () => { + const element = document.createElement("div"); + element.id = "puck-a"; + return { id: "puck-a", selector: "#puck-a", element } as unknown as DomEditSelection; + }; + const preview = ( + sel: DomEditSelection, + anim: GsapAnimation, + ends: Record, + from = false, + ) => + previewWith(sel.element, [ + liveTween( + sel.element, + { start: anim.resolvedStart!, duration: anim.duration!, vars: anim.properties, ends }, + { from }, + ), + ]); - it("extends the existing tween (never spawns a parallel one) when dragged OUTSIDE its range", async () => { - // fallow-ignore-next-line code-duplication - usePlayerStore.setState({ currentTime: 6 }); // outside [1.2, 3.4] - const { types, callbacks } = recordingCallbacks(); - - await commitGsapPositionFromDrag( - selection(), - flatTween(), - { x: -100, y: 0 }, - { x: 0, y: 0 }, - null, - "#puck-a", - callbacks, - ); - - expect(types).toContain("convert-to-keyframes"); - expect(types).toContain("replace-with-keyframes"); // the extend - expect(types).not.toContain("add-with-keyframes"); // regression: no parallel tween - }); - - it("adds a keyframe at the playhead when dragged INSIDE its range", async () => { - // fallow-ignore-next-line code-duplication - usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4] - // fallow-ignore-next-line code-duplication - const { types, callbacks } = recordingCallbacks(); - - await commitGsapPositionFromDrag( - selection(), - flatTween(), - { x: -100, y: 0 }, - { x: 0, y: 0 }, - null, - "#puck-a", - callbacks, - ); - - expect(types).toContain("add-keyframe"); - expect(types).not.toContain("add-with-keyframes"); - }); - - it("MODIFIES the selected keyframe (no extend) when one is selected, even past the tween end", async () => { - // User clicked the 100% diamond (activeKeyframePct=100), playhead drifted past - // the end. Expect: convert + add-keyframe AT 100% — not replace-with-keyframes. + it("changes the selected keyframe without extending, even with the playhead past the end", async () => { usePlayerStore.setState({ currentTime: 6, activeKeyframePct: 100 }); // outside [1.2, 3.4] - // fallow-ignore-next-line code-duplication - const { types, mutations, callbacks } = recordingCallbacks(); - + const { mutations, callbacks } = recordingCallbacks(); + const sel = puck(); await commitGsapPositionFromDrag( - selection(), + sel, flatTween(), { x: -100, y: 0 }, { x: 0, y: 0 }, - null, - "#puck-a", + preview(sel, flatTween(), { x: [0, -260] }), callbacks, ); - - expect(types).toContain("add-keyframe"); - expect(types).not.toContain("replace-with-keyframes"); // not extended - const addKf = mutations.find((m) => m.type === "add-keyframe"); - expect(addKf?.percentage).toBe(100); // modified the selected endpoint - // consumed: cleared so the next free drag doesn't keep modifying + expect(mutations).toEqual([ + expect.objectContaining({ + type: "replace-with-keyframes", + position: 1.2, + duration: 2.2, + easeEach: "power1.out", + keyframes: [{ percentage: 100, properties: { x: -100, y: 0 } }], + }), + ]); expect(usePlayerStore.getState().activeKeyframePct).toBeNull(); - // parked the playhead on the edited keyframe (1.2 start + 100% * 2.2 dur), - // so the edit is visible instead of rendering the base pose expect(usePlayerStore.getState().requestedSeekTime).toBe(3.4); }); -}); -describe("commitGsapPositionFromDrag — keyframed tween backfill", () => { - beforeEach(() => { - usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }); - }); - - const keyframedTween = (): GsapAnimation => - ({ - id: "#puck-a-kf", - targetSelector: "#puck-a", - method: "to", - resolvedStart: 1.2, - duration: 2.2, - keyframes: { - keyframes: [ - { percentage: 0, properties: { x: 0 } }, - { percentage: 100, properties: { x: -260 } }, - ], - }, - }) as unknown as GsapAnimation; - - it("passes backfillDefaults so a newly-introduced prop doesn't move the other keyframes", async () => { - // Drag the 0% keyframe DOWN (introduces y on an x-only tween). The add-keyframe - // must carry backfillDefaults at the element's base so 100% gets y:0, not y:780. - usePlayerStore.setState({ currentTime: 1.2, activeKeyframePct: 0 }); - const { mutations, callbacks } = recordingCallbacks(); - - await commitGsapPositionFromDrag( - selection(), - keyframedTween(), - { x: 0, y: 780 }, // studioOffset: dragged straight down - { x: 0, y: 0 }, // gsapPos → base falls back to {0,0} (selection has no base attrs) - null, - "#puck-a", - callbacks, - ); - - const addKf = mutations.find((m) => m.type === "add-keyframe"); - expect(addKf).toBeDefined(); - expect(addKf?.percentage).toBe(0); // edited the selected 0% keyframe - expect(addKf?.properties).toMatchObject({ y: 780 }); - expect(addKf?.backfillDefaults).toEqual({ x: 0, y: 0 }); // base → 100% gets y:0 - }); -}); - -describe("commitGsapPositionFromDrag — from() tween dragged outside its range", () => { - beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null })); - - const fromTween = (): GsapAnimation => - ({ - id: "#title-from-400", + it("extends a from() tween to the playhead, keeping its parsed end as the old end", async () => { + usePlayerStore.setState({ currentTime: 2.2 }); // outside [0.4, 1.3] + const fromTween = { + id: "#title-from-400-position", targetSelector: "#title", method: "from", + propertyGroup: "position", resolvedStart: 0.4, duration: 0.9, + ease: "none", properties: { y: 70 }, - }) as unknown as GsapAnimation; - - it("REPLACES the split position from() tween (no parallel tween → no drop jump)", async () => { - usePlayerStore.setState({ currentTime: 2.13 }); // outside [0.4, 1.3] - const types: string[] = []; - const mutations: Array> = []; - const callbacks: GsapDragCommitCallbacks = { - commitMutation: async (_s, m) => { - types.push(m.type as string); - mutations.push(m); - }, - // After split-into-property-groups, the position group is a from() tween (no keyframes). - fetchAnimations: async () => [ - { - id: "#title-from-400-position", - targetSelector: "#title", - method: "from", - propertyGroup: "position", - resolvedStart: 0.4, - duration: 0.9, - properties: { y: 70 }, - } as unknown as GsapAnimation, - ], - }; - + } as unknown as GsapAnimation; + const { types, mutations, callbacks } = recordingCallbacks(); + const sel = puck(); await commitGsapPositionFromDrag( - selection(), - fromTween(), + sel, + fromTween, { x: 0, y: -333 }, { x: 0, y: 0 }, - null, - "#title", + preview(sel, fromTween, { y: [70, 0] }, true), callbacks, ); - - expect(types).toContain("split-into-property-groups"); - expect(types).toContain("replace-with-keyframes"); - expect(types).not.toContain("add-with-keyframes"); // regression: no parallel tween - const replace = mutations.find((m) => m.type === "replace-with-keyframes"); - expect(replace?.animationId).toBe("#title-from-400-position"); // replaces the split from() + expect(types).toEqual(["replace-with-keyframes"]); + expect(mutations[0]).toMatchObject({ + animationId: "#title-from-400-position", + position: 0.4, + duration: 1.8, + easeEach: "none", + keyframes: [ + { percentage: 0, properties: { y: 70, x: 0 } }, + { percentage: 50, properties: { y: 0, x: 0 } }, + { percentage: 100, properties: { y: -333, x: 0 } }, + ], + }); }); }); @@ -581,32 +493,31 @@ describe("commitStaticGsapRotation — instantPatch (value-only set)", () => { }); }); -describe("commitGsapPositionFromDrag — keyframe/structural commits omit instantPatch", () => { +describe("commitGsapPositionFromDrag — keyframe commits omit instantPatch", () => { beforeEach(() => usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null })); - it("a structural keyframe drag (convert-to-keyframes → add-keyframe) sets no instantPatch", async () => { - usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4] → convert + add-keyframe + it("a keyframe drag relies on the soft reload, not the instant patch", async () => { + usePlayerStore.setState({ currentTime: 2 }); // inside [1.2, 3.4] const { commits, callbacks } = optionRecordingCallbacks(); - + const element = document.createElement("div"); + const sel = { id: "puck-a", selector: "#puck-a", element } as unknown as DomEditSelection; + const live = liveTween(element, { + start: 1.2, + duration: 2.2, + vars: { x: -260 }, + ends: { x: [0, -260] }, + }); await commitGsapPositionFromDrag( - selection(), + sel, flatTween(), { x: -100, y: 0 }, { x: 0, y: 0 }, - null, - "#puck-a", + previewWith(element, [live]), callbacks, ); - - // The keyframe path is structural here (convert + add-keyframe) and must rely - // on the soft reload — none of its commits opt into the instant patch. - expect(commits.length).toBeGreaterThan(0); - for (const c of commits) { - expect(c.options.instantPatch).toBeUndefined(); - } - const types = commits.map((c) => c.mutation.type); - expect(types).toContain("convert-to-keyframes"); - expect(types).toContain("add-keyframe"); + expect(commits.map((c) => c.mutation.type)).toEqual(["replace-with-keyframes"]); + expect(commits[0]!.options.instantPatch).toBeUndefined(); + expect(commits[0]!.options.softReload).toBe(true); }); }); diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index 264f2a6903..ef406a14a6 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -4,14 +4,10 @@ */ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import { - STUDIO_ORIGINAL_WIDTH_ATTR, - STUDIO_ORIGINAL_HEIGHT_ATTR, -} from "../components/editor/manualEditsTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; import { roundTo3, roundToLayoutPx } from "../utils/rounding"; -import { computeElementPercentage, writeTargetSelector } from "./gsapShared"; +import { computeElementPercentage, keyframeEases, writeTargetSelector } from "./gsapShared"; import { computeDraggedGsapPosition } from "./draggedGsapPosition"; import type { RuntimeTweenChange } from "./gsapRuntimePatch"; import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction"; @@ -313,104 +309,6 @@ export async function commitStaticGsapSize( ); } -/** Rounded `n` when it's a positive finite number, else `fallback`. */ -function positiveOr(n: number, fallback: number): number { - return Number.isFinite(n) && n > 0 ? roundToLayoutPx(n) : fallback; -} - -/** - * Prior size for a keyframed resize: the existing global set's value, else the - * element's pre-resize size (the draft saved it on the element before mutating - * el.style.width/height). Falls back to the new size when neither is available. - */ -function resolvePriorSize( - sizeSet: GsapAnimation | null, - el: Element | null | undefined, - fallbackW: number, - fallbackH: number, -): { width: number; height: number } { - if (sizeSet) { - return { - width: positiveOr(Number(sizeSet.properties.width), fallbackW), - height: positiveOr(Number(sizeSet.properties.height), fallbackH), - }; - } - const ow = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_WIDTH_ATTR) ?? ""); - const oh = Number.parseFloat(el?.getAttribute(STUDIO_ORIGINAL_HEIGHT_ATTR) ?? ""); - return { width: positiveOr(ow, fallbackW), height: positiveOr(oh, fallbackH) }; -} - -/** - * Resize an *animated* element by keyframing its size at the current playhead, - * instead of a global `gsap.set` hold. Builds a width/height keyframe tween - * aligned to the element's existing animation: every base keyframe keeps the - * prior size, only the keyframe nearest the playhead gets the new size — so - * resizing one keyframe leaves the others unchanged. Replaces any prior global - * size set. Returns false when there's no usable range (caller falls back to the - * static set). - */ -export async function commitKeyframedSizeFromResize( - selection: DomEditSelection, - size: { width: number; height: number }, - selector: string, - sizeSet: GsapAnimation | null, - animatedTween: GsapAnimation, - callbacks: GsapDragCommitCallbacks, -): Promise { - const ts = resolveTweenStart(animatedTween) ?? 0; - const td = resolveTweenDuration(animatedTween); - if (!(td > 0)) return false; - - const newW = roundToLayoutPx(size.width); - const newH = roundToLayoutPx(size.height); - const prior = resolvePriorSize(sizeSet, selection.element, newW, newH); - - const ct = usePlayerStore.getState().currentTime; - const pct = Math.max(0, Math.min(100, Math.round(((ct - ts) / td) * 1000) / 10)); - - // Base keyframe percentages from the animated tween (flat tween → 0 & 100), - // plus the endpoints and the playhead. Each keeps the prior size except the - // keyframe at the playhead, which gets the new size. - const pcts = new Set( - animatedTween.keyframes?.keyframes.map((k) => k.percentage) ?? [0, 100], - ); - pcts.add(0); - pcts.add(100); - pcts.add(pct); - const keyframes = Array.from(pcts) - .sort((a, b) => a - b) - .map((p) => ({ - percentage: p, - properties: Math.abs(p - pct) < 0.05 ? { width: newW, height: newH } : { ...prior }, - })); - - // Add the size keyframe tween FIRST, then delete the old global hold. The gesture - // transport applies both in one ordered batch; a plain commit fallback keeps the - // same recoverable ordering. Only the transaction's result triggers the reload. - const addLabel = `Resize (size keyframe ${pct.toFixed(0)}%)`; - const target = newTweenTarget(selection); - if (!target) return false; - await callbacks.commitMutation( - selection, - { - type: "add-with-keyframes", - targetSelector: target, - position: roundTo3(ts), - duration: roundTo3(td), - keyframes, - }, - sizeSet ? { label: addLabel, skipReload: true } : { label: addLabel, softReload: true }, - ); - if (sizeSet) { - await callbacks.commitMutation( - selection, - { type: "delete", animationId: sizeSet.id }, - { label: "Resize layer", softReload: true }, - ); - } - return true; -} - // ── Whole-path offset (plain drag on animated element) ────────────────── /** @@ -456,7 +354,6 @@ export async function commitWholePathOffset( const ts = resolveTweenStart(effectiveAnim); const td = resolveTweenDuration(effectiveAnim); - const ease = effectiveAnim.keyframes?.easeEach ?? effectiveAnim.ease; let kfs = effectiveAnim.keyframes?.keyframes ?? []; if (kfs.length === 0) { @@ -493,7 +390,7 @@ export async function commitWholePathOffset( position: roundTo3(ts ?? 0), duration: roundTo3(td || 1), keyframes: shifted, - ease, + ...keyframeEases(effectiveAnim), }, { label: "Move animation path", softReload: true, beforeReload: restoreOffset }, ); diff --git a/packages/studio/src/hooks/gsapDragPositionCommit.ts b/packages/studio/src/hooks/gsapDragPositionCommit.ts index 7610e7e124..21013fe006 100644 --- a/packages/studio/src/hooks/gsapDragPositionCommit.ts +++ b/packages/studio/src/hooks/gsapDragPositionCommit.ts @@ -1,8 +1,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; -import { PROPERTY_GROUPS } from "@hyperframes/parsers/gsap-constants"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; -import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; +import { resolveTweenStart } from "../utils/globalTimeCompiler"; import { KEYFRAME_PCT_MATCH, resolveEditableTweenDuration } from "./gsapShared"; import { roundTo3 } from "../utils/rounding"; import { computeDraggedGsapPosition } from "./draggedGsapPosition"; @@ -10,8 +9,9 @@ import { type GsapDragCommitCallbacks, computeCurrentPercentage, parkPlayheadOnKeyframe, - materializeIfDynamic, } from "./gsapDragCommit"; +import type { GsapEditOutcome } from "./gsapEditOutcome"; +import { commitValueAtPlayhead } from "./gsapValueAtPlayhead"; /** * The tween's keyframes with one inserted at `percentage`. Any existing keyframe @@ -38,234 +38,6 @@ export function buildTemporalArcKeyframes( ].sort((a, b) => a.percentage - b.percentage); } -async function extendTweenAndAddKeyframe( - selection: DomEditSelection, - anim: GsapAnimation, - properties: Record, - targetTime: number, - tweenStart: number, - tweenDuration: number, - callbacks: GsapDragCommitCallbacks, - beforeReload?: () => void, - backfillDefaults?: Record, -): Promise { - 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 }> = - []; - for (const kf of existingKfs) { - const absTime = tweenStart + (kf.percentage / 100) * tweenDuration; - const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10; - const props: Record = { ...kf.properties }; - for (const k of Object.keys(properties)) { - if (!(k in props) && backfillDefaults?.[k] != null) props[k] = backfillDefaults[k]; - } - remappedKfs.push({ percentage: newPct, properties: props }); - } - - 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, - callbacks: GsapDragCommitCallbacks, - beforeReload?: () => void, - backfillDefaults?: Record, -): Promise { - const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); - const computedPct = computeCurrentPercentage(selection, anim); - const pct = activeKeyframePct ?? computedPct; - await callbacks.commitMutation( - selection, - { - type: "add-keyframe", - animationId: anim.id, - percentage: pct, - properties, - ...(backfillDefaults ? { backfillDefaults } : {}), - }, - { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload }, - ); - if (activeKeyframePct != null) { - setActiveKeyframePct(null); - parkPlayheadOnKeyframe(anim, pct); - } -} - -interface DragRuntimeGsap { - getProperty: (target: Element, key: string) => unknown; - set: (target: Element, vars: Record) => void; -} -interface DragRuntimeTimeline { - seek: (time: number) => void; -} -interface DragRuntime { - gsapLib: DragRuntimeGsap; - el: Element; - mainTl: DragRuntimeTimeline; -} - -function resolveDragRuntime( - iframe: HTMLIFrameElement | null | undefined, - selector: string | undefined, -): DragRuntime | null { - if (!iframe || !selector) return null; - const win = iframe.contentWindow as - | (Window & { - gsap?: Partial; - __timelines?: Record>; - }) - | null; - const gsap = win?.gsap; - if (typeof gsap?.getProperty !== "function" || typeof gsap.set !== "function") return null; - let el: Element | null = null; - try { - el = iframe.contentDocument?.querySelector(selector) ?? null; - } catch { - return null; - } - if (!el) return null; - const timelines = win?.__timelines; - const mainTl = timelines ? Object.values(timelines)[0] : undefined; - if (typeof mainTl?.seek !== "function") return null; - return { - gsapLib: gsap as DragRuntimeGsap, - el, - mainTl: mainTl as DragRuntimeTimeline, - }; -} - -// fallow-ignore-next-line complexity -async function commitFlatViaKeyframes( - selection: DomEditSelection, - anim: GsapAnimation, - properties: Record, - callbacks: GsapDragCommitCallbacks, - beforeReload?: () => void, - iframe?: HTMLIFrameElement | null, - selector?: string, - backfillDefaults?: Record, -): Promise { - const ct = usePlayerStore.getState().currentTime; - const ts = resolveTweenStart(anim); - const td = resolveEditableTweenDuration(anim, selection); - const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); - const outsideRange = - activeKeyframePct == null && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); - - const resolvedFromValues: Record = {}; - const runtime = resolveDragRuntime(iframe, selector); - if (runtime && ts !== null) { - const { gsapLib, el, mainTl } = runtime; - const draggedValues: Record = {}; - const channelsTheClearWipes = new Set([ - ...Object.keys(properties), - ...PROPERTY_GROUPS.position, - ]); - for (const key of channelsTheClearWipes) { - const v = Number(gsapLib.getProperty(el, key)); - if (Number.isFinite(v)) draggedValues[key] = v; - } - try { - gsapLib.set(el, { clearProps: Object.keys(properties).join(",") }); - 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 { - for (const key of Object.keys(resolvedFromValues)) delete resolvedFromValues[key]; - } finally { - if (Object.keys(draggedValues).length > 0) gsapLib.set(el, draggedValues); - } - } - - if (outsideRange && ts !== null) { - const coalesceKey = `gsap:convert-drag:${anim.id}`; - await callbacks.commitMutation( - selection, - { - type: "convert-to-keyframes", - animationId: anim.id, - ...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}), - }, - { label: "Convert to keyframes for drag", skipReload: true, coalesceKey }, - ); - const fresh = callbacks.fetchAnimations ? await callbacks.fetchAnimations() : []; - // By id first: a target with several tweens (two `to`s on the same selector) - // matches the selector lookup on whichever one happens to be first, and the - // extend-and-add below would then rewrite a tween the drag never touched. - const converted = - fresh.find((a) => a.id === anim.id && a.keyframes) ?? - fresh.find((a) => a.targetSelector === anim.targetSelector && a.keyframes) ?? - anim; - const convertedStart = resolveTweenStart(converted) ?? ts; - const convertedDur = resolveTweenDuration(converted) || td; - await extendTweenAndAddKeyframe( - selection, - converted, - properties, - ct, - convertedStart, - convertedDur, - callbacks, - beforeReload, - ); - return; - } - - const coalesceKey = `gsap:convert-drag:${anim.id}`; - // fallow-ignore-next-line code-duplication - await callbacks.commitMutation( - selection, - { - type: "convert-to-keyframes", - animationId: anim.id, - ...(Object.keys(resolvedFromValues).length > 0 ? { resolvedFromValues } : {}), - }, - { label: "Convert to keyframes for drag", skipReload: true, coalesceKey }, - ); - const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); - const editedSelected = activeKeyframePct != null; - if (editedSelected) setActiveKeyframePct(null); - - await callbacks.commitMutation( - selection, - { - type: "add-keyframe", - animationId: anim.id, - percentage: pct, - properties, - ...(backfillDefaults ? { backfillDefaults } : {}), - }, - { label: `Move layer (keyframe ${pct}%)`, softReload: true, beforeReload, coalesceKey }, - ); - if (editedSelected) parkPlayheadOnKeyframe(anim, pct); -} - // fallow-ignore-next-line code-duplication // fallow-ignore-next-line complexity export async function commitGsapPositionFromDrag( @@ -274,9 +46,8 @@ export async function commitGsapPositionFromDrag( studioOffset: { x: number; y: number }, gsapPos: { x: number; y: number }, iframe: HTMLIFrameElement | null, - selector: string, callbacks: GsapDragCommitCallbacks, -): Promise { +): Promise { const el = selection.element; // fallow-ignore-next-line code-duplication const { newX, newY, baseGsapX, baseGsapY } = computeDraggedGsapPosition( @@ -293,8 +64,6 @@ export async function commitGsapPositionFromDrag( el.removeAttribute("data-hf-drag-initial-offset-y"); }; - const backfillDefaults: Record = { x: baseGsapX, y: baseGsapY }; - const ct = usePlayerStore.getState().currentTime; if (anim.arcPath?.enabled) { const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); @@ -319,14 +88,15 @@ export async function commitGsapPositionFromDrag( ); setActiveKeyframePct(null); parkPlayheadOnKeyframe(anim, pct); - return; + return { status: "persisted" }; } const tweenStart = resolveTweenStart(anim); // Clip-wide, same as applyArcKeyframeAtPlayhead: authoring GSAP's 0.5s // default here would collapse a duration-less arc's window on drag. const tweenDuration = resolveEditableTweenDuration(anim, selection); - if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2) return; + if (tweenStart === null || tweenDuration <= 0 || keyframes.length < 2) + return { status: "persisted" }; const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY }); await callbacks.commitMutation( selection, @@ -341,156 +111,11 @@ export async function commitGsapPositionFromDrag( }, { label: "Move layer (new keyframe)", softReload: true, beforeReload: restoreOffset }, ); - return; - } - if (anim.keyframes) { - const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); - const effectiveAnim = newId ? { ...anim, id: newId } : anim; - const dragProps: Record = { x: newX, y: newY }; - - const ts = resolveTweenStart(effectiveAnim); - const td = resolveEditableTweenDuration(effectiveAnim, selection); - const outsideRange = ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); - const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null; - if (outsideRange && !hasSelectedKeyframe) { - await extendTweenAndAddKeyframe( - selection, - effectiveAnim, - dragProps, - ct, - ts, - td, - callbacks, - restoreOffset, - backfillDefaults, - ); - } else { - await commitKeyframedPosition( - selection, - effectiveAnim, - dragProps, - callbacks, - restoreOffset, - backfillDefaults, - ); - } - } else if (anim.method === "from" || anim.method === "fromTo") { - const ct = usePlayerStore.getState().currentTime; - const ts = resolveTweenStart(anim); - const td = resolveEditableTweenDuration(anim, selection); - const hasSelectedKeyframe = usePlayerStore.getState().activeKeyframePct != null; - const outsideRange = - !hasSelectedKeyframe && ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01); - const dragProps: Record = { x: newX, y: newY }; - - if (outsideRange && ts !== null) { - 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) { - const posTs = resolveTweenStart(existingPosAnim); - const posTd = resolveEditableTweenDuration(existingPosAnim, selection); - if (posTs !== null) { - await extendTweenAndAddKeyframe( - selection, - existingPosAnim, - { x: newX, y: newY }, - ct, - posTs, - posTd, - callbacks, - restoreOffset, - backfillDefaults, - ); - return; - } - } - - 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); - - const baseKf = { - targetSelector: anim.targetSelector, - position: roundTo3(newStart), - duration: roundTo3(newDuration), - keyframes, - }; - await callbacks.commitMutation( - selection, - existingPosAnim - ? { type: "replace-with-keyframes", animationId: existingPosAnim.id, ...baseKf } - : { type: "add-with-keyframes", ...baseKf }, - { label: "Move layer (from extended)", softReload: true, beforeReload: restoreOffset }, - ); - } else { - 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 { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); - const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); - if (activeKeyframePct != null) setActiveKeyframePct(null); - await callbacks.commitMutation( - selection, - { - type: "add-keyframe", - animationId: anim.id, - percentage: pct, - properties: dragProps, - ...(backfillDefaults ? { backfillDefaults } : {}), - }, - { - label: `Move layer (keyframe ${pct}%)`, - softReload: true, - beforeReload: restoreOffset, - coalesceKey, - }, - ); - } - } else { - await commitFlatViaKeyframes( - selection, - anim, - { x: newX, y: newY }, - callbacks, - restoreOffset, - iframe, - selector, - backfillDefaults, - ); + return { status: "persisted" }; } + return commitValueAtPlayhead(selection, anim, { x: newX, y: newY }, iframe, callbacks, { + label: "Move layer", + backfill: { x: baseGsapX, y: baseGsapY }, + beforeReload: restoreOffset, + }); } diff --git a/packages/studio/src/hooks/gsapEditOutcome.ts b/packages/studio/src/hooks/gsapEditOutcome.ts index 68ef4dcf5b..e33f8e08ef 100644 --- a/packages/studio/src/hooks/gsapEditOutcome.ts +++ b/packages/studio/src/hooks/gsapEditOutcome.ts @@ -1,6 +1,10 @@ import { editabilityForProvenance, type GsapAnimation } from "@hyperframes/core/gsap-parser"; -export type GsapEditBlockReason = "no-selector" | "unroll-required" | "source-uneditable"; +export type GsapEditBlockReason = + | "no-selector" + | "unroll-required" + | "source-uneditable" + | "keyframes-uneditable"; /** * Which of the nine situations produced a block. The user-facing `reason` stays @@ -21,7 +25,19 @@ export type GsapEditBlockDetail = | "no-position-tween" | "live-rotation-no-source-tween" | "live-resize-no-source-tween" - | "zero-duration-tween"; + | "zero-duration-tween" + | PlayheadEditRefusal; + +/** Why an edit at the playhead could not be written as keyframes of the file's tween. */ +export type PlayheadEditRefusal = + | "eased-keyframes" + | "simple-array-keyframes" + | "unknown-ease" + | "implicit-end-unknown" + | "not-a-tween" + | "tween-extras" + | "no-timing" + | "shared-tween"; export type GsapEditOutcome = | { @@ -52,6 +68,8 @@ export const GSAP_EDIT_BLOCK_COPY: Record = { "unroll-required": "This motion comes from a helper or loop. Choose Unroll to edit it explicitly.", "source-uneditable": "This animation is computed at runtime. Edit the animation in the Code tab.", + "keyframes-uneditable": + "Studio can't add this edit to the animation's keyframes. Edit this animation in the Code tab.", }; export class GsapEditBlockedError extends Error { diff --git a/packages/studio/src/hooks/gsapParsedTween.test-helpers.ts b/packages/studio/src/hooks/gsapParsedTween.test-helpers.ts new file mode 100644 index 0000000000..216b5b6259 --- /dev/null +++ b/packages/studio/src/hooks/gsapParsedTween.test-helpers.ts @@ -0,0 +1,45 @@ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; + +/** A property's `[start, end]` as GSAP parsed it from the file when the tween first rendered. */ +type Ends = Record; + +/** A GSAP 3 tween as the preview runtime holds it, with the property tweens the parse readers walk. */ +export function liveTween( + target: Element, + tween: { start: number; duration: number; vars: Record; ends?: Ends }, + { from = false, parts }: { from?: boolean; parts?: unknown[] } = {}, +) { + let head: Record | undefined; + for (const [p, [s, e]] of Object.entries(tween.ends ?? {}).reverse()) + head = from ? { p, s: e, c: s - e, _next: head } : { p, s, c: e - s, _next: head }; + return { + targets: () => [target], + startTime: () => tween.start, + duration: () => tween.duration, + vars: tween.vars, + _from: from, + ...(head && { _pt: { d: { _pt: head } } }), + ...(parts && { timeline: { getChildren: () => parts } }), + }; +} + +/** A preview iframe whose one timeline holds `tweens`, answering `getProperty` from `live`. */ +export function previewWith( + element: Element, + tweens: unknown[], + live: Record = {}, +): HTMLIFrameElement { + return { + contentWindow: { + __timelines: { main: { getChildren: () => tweens, duration: () => 10 } }, + gsap: { + getProperty: (_el: Element, prop: string) => live[prop] ?? 0, + defaults: () => ({ ease: "power1.out" }), + }, + }, + contentDocument: element.ownerDocument, + } as unknown as HTMLIFrameElement; +} + +export const tween = (fields: Partial): GsapAnimation => + ({ targetSelector: "#box", propertyGroup: "position", ...fields }) as GsapAnimation; diff --git a/packages/studio/src/hooks/gsapParsedTween.ts b/packages/studio/src/hooks/gsapParsedTween.ts new file mode 100644 index 0000000000..eb29bcdcf6 --- /dev/null +++ b/packages/studio/src/hooks/gsapParsedTween.ts @@ -0,0 +1,113 @@ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { resolveTweenStart } from "../utils/globalTimeCompiler"; +import type { ImplicitEndValue } from "./gsapValueAtPlayhead"; + +// GSAP 3 internals: a property tween in a tween's `_pt` chain; CSSPlugin keeps its own under `d._pt`. +interface PropTween { + p?: string; + s?: number; + c?: number; + d?: { _pt?: PropTween }; + _next?: PropTween; +} +interface ParsedTween { + _pt?: PropTween; + _from?: boolean; + vars?: Record; + parent?: { vars?: { defaults?: { ease?: unknown } } }; + timeline?: { getChildren?: () => ParsedTween[] }; + targets?: () => Element[]; + startTime?: () => number; + duration?: () => number; +} +interface GsapWindow { + gsap?: { defaults?: () => { ease?: unknown } }; + __timelines?: Record ParsedTween[] }>; +} + +// `scale` parses into the two longhands, which always share a start and end for a `scale` tween. +const PARSED_NAME: Record = { scale: "scaleX", rotate: "rotation" }; + +function findPropTween(pt: PropTween | undefined, prop: string): PropTween | null { + for (let node = pt; node; node = node._next) { + if (node.p === prop && typeof node.s === "number" && typeof node.c === "number") return node; + const nested = findPropTween(node.d?._pt, prop); + if (nested) return nested; + } + return null; +} + +/** `[start, end]` of `prop` in one initialised tween; a from() tween runs its pair backwards. */ +function endsIn(tween: ParsedTween, prop: string): [number, number] | null { + const pt = findPropTween(tween._pt, PARSED_NAME[prop] ?? prop); + if (!pt) return null; + const pair: [number, number] = [pt.s!, pt.s! + pt.c!]; + return tween._from ? [pair[1], pair[0]] : pair; +} + +/** The live tween GSAP built from `anim`: same element, start and channels. */ +// fallow-ignore-next-line complexity +export function findParsedTween( + iframe: HTMLIFrameElement | null, + element: Element, + anim: GsapAnimation, +): ParsedTween | null { + const win = iframe?.contentWindow as GsapWindow | null; + const start = resolveTweenStart(anim); + if (!win?.__timelines || start == null) return null; + const props = Object.keys(anim.keyframes?.keyframes[0]?.properties ?? anim.properties); + for (const timeline of Object.values(win.__timelines)) { + for (const tween of timeline?.getChildren?.(true) ?? []) { + const targets = tween.targets?.() ?? []; + if (!targets.includes(element) && !targets.some((t) => element.id && t.id === element.id)) + continue; + if (Math.abs((tween.startTime?.() ?? Number.NaN) - start) > 1e-3) continue; + const vars = tween.vars ?? {}; + const carries = props.some((p) => p in vars) || "keyframes" in vars; + if (carries && (tween.duration?.() ?? 0) > 0) return tween; + } + } + return null; +} + +/** Start and end values from GSAP's own parse of the tween, as loaded from the file. Null when + * GSAP has not initialised that part of the tween yet: the caller refuses rather than guess. */ +export function parsedImplicitEndValue(tween: ParsedTween | null): ImplicitEndValue { + return (prop, end) => { + if (!tween) return null; + const parts = tween.timeline?.getChildren?.() ?? []; + const ordered = parts.length > 0 ? (end === "start" ? parts : [...parts].reverse()) : [tween]; + for (const part of ordered) { + const pair = endsIn(part, prop); + if (pair) return end === "start" ? pair[0] : pair[1]; + // The nearest part animating `prop` is not initialised: an earlier one would be a wrong value. + if (prop in (part.vars ?? {})) return null; + } + return null; + }; +} + +/** The ease GSAP resolved for a flat tween that authors none: its timeline's default, else GSAP's. */ +export function parsedTweenEase(iframe: HTMLIFrameElement | null, tween: ParsedTween | null) { + if (!tween) return null; + const win = iframe?.contentWindow as GsapWindow | null; + const ease = + tween.vars?.ease ?? tween.parent?.vars?.defaults?.ease ?? win?.gsap?.defaults?.().ease; + return typeof ease === "string" ? ease : null; +} + +/** An array of keyframe steps at the exact percentages GSAP times them; the parse rounds them. */ +export function withExactStepTimes(anim: GsapAnimation, tween: ParsedTween | null): GsapAnimation { + const data = anim.keyframes; + const parts = tween?.timeline?.getChildren?.() ?? []; + const total = tween?.duration?.() ?? 0; + if (data?.format !== "object-array" || parts.length !== data.keyframes.length || !(total > 0)) + return anim; + const ends = parts.map((part) => ((part.startTime?.() ?? 0) + (part.duration?.() ?? 0)) / total); + if (ends.some((end) => !Number.isFinite(end))) return anim; + const keyframes = data.keyframes.map((kf, i) => ({ + ...kf, + percentage: Math.round(ends[i]! * 100000) / 1000, + })); + return { ...anim, keyframes: { ...data, keyframes } }; +} diff --git a/packages/studio/src/hooks/gsapPositionDetection.ts b/packages/studio/src/hooks/gsapPositionDetection.ts index f140930df9..4006ad1615 100644 --- a/packages/studio/src/hooks/gsapPositionDetection.ts +++ b/packages/studio/src/hooks/gsapPositionDetection.ts @@ -7,7 +7,7 @@ */ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { usePlayerStore } from "../player/store/playerStore"; -import { getIframeGsap, queryIframeElement } from "./gsapShared"; +import { getIframeGsap, KEYFRAME_PCT_MATCH, queryIframeElement } from "./gsapShared"; import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; // fallow-ignore-next-line complexity @@ -47,33 +47,41 @@ export function findGsapPositionAnimation( if (animations.length === 0) return null; const currentTime = usePlayerStore.getState().currentTime; - const scored = animations - .filter((a) => animHasPosition(a) || a.keyframes || animations.length === 1) - .map((a) => { - let score = 0; - if (animHasPosition(a)) score += 10; - if (a.keyframes) score += 5; - if (selector && a.targetSelector === selector) score += 8; - else if (a.targetSelector.includes(",")) score -= 5; - 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 += 50; - else - score -= Math.round( - Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5, - ); - return { anim: a, score }; - }); + const scored = animations.filter(animHasPosition).map((a) => { + let score = 0; + if (a.keyframes) score += 5; + if (selector && a.targetSelector === selector) score += 8; + else if (a.targetSelector.includes(",")) score -= 5; + 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 += 50; + else + score -= Math.round( + Math.min(Math.abs(currentTime - pos), Math.abs(currentTime - pos - dur)) * 5, + ); + return { anim: a, score }; + }); scored.sort((a, b) => b.score - a.score); - return scored[0]?.anim ?? animations[0]; + return scored[0]?.anim ?? null; +} + +/** Whether the file states `anim`'s value at `time`; a tween's implicit start is GSAP's current value. */ +function statesValueAt(anim: GsapAnimation, time: number): boolean { + const duration = resolveTweenDuration(anim); + if (!(duration > 0)) return false; + const pct = ((time - (resolveTweenStart(anim) ?? 0)) / duration) * 100; + const near = (keyframe: number) => Math.abs(keyframe - pct) <= KEYFRAME_PCT_MATCH; + if (anim.keyframes) return anim.keyframes.keyframes.some((kf) => near(kf.percentage)); + if (anim.method === "from") return near(0); + return near(100) || (anim.method === "fromTo" && near(0)); } /** * From a set of candidate tweens, pick the one whose time range is closest to * the current playhead. A tween that *contains* the playhead wins outright; * otherwise the nearest endpoint wins. This ensures a drag at t=6s edits (or - * extends) the 4s tween, not the 1.5s one. Tie-break: most keyframes (so a - * gesture-recorded tween beats a stub when both are equidistant). + * extends) the 4s tween, not the 1.5s one. Ties: the one whose file states a value at the + * playhead (the later one, which renders, if both do), then most keyframes. */ // fallow-ignore-next-line complexity export function pickClosestToPlayhead(anims: GsapAnimation[]): GsapAnimation | null { @@ -88,6 +96,11 @@ export function pickClosestToPlayhead(anims: GsapAnimation[]): GsapAnimation | n const bestDist = ct >= bestS && ct <= bestE ? 0 : Math.min(Math.abs(ct - bestS), Math.abs(ct - bestE)); if (dist < bestDist) return a; + if (dist === 0 && bestDist === 0) { + const [mine, theirs] = [statesValueAt(a, ct), statesValueAt(best, ct)]; + if (mine !== theirs) return mine ? a : best; + if (mine && s !== bestS) return s > bestS ? a : best; + } if ( dist === bestDist && (a.keyframes?.keyframes.length ?? 0) > (best.keyframes?.keyframes.length ?? 0) diff --git a/packages/studio/src/hooks/gsapResizeDropPoint.test.ts b/packages/studio/src/hooks/gsapResizeDropPoint.test.ts index 19d5c816d4..c1ff52645c 100644 --- a/packages/studio/src/hooks/gsapResizeDropPoint.test.ts +++ b/packages/studio/src/hooks/gsapResizeDropPoint.test.ts @@ -148,17 +148,18 @@ function positionAnimation( } as unknown as GsapAnimation; } -/** Every x/y a run wrote, in commit order. */ +/** Every x/y a run wrote AT THE PLAYHEAD (percentage 0 here), in commit order. */ function persistedPositions(calls: unknown[][]): Array<{ x?: number; y?: number }> { const written: Array<{ x?: number; y?: number }> = []; for (const call of calls) { const mutation = call[1] as { properties?: Record; - keyframes?: Array<{ properties: Record }>; + keyframes?: Array<{ percentage: number; properties: Record }>; x?: number; y?: number; }; - const sources = [mutation.properties, ...(mutation.keyframes ?? []).map((k) => k.properties)]; + const atPlayhead = (mutation.keyframes ?? []).filter((k) => k.percentage === 0); + const sources = [mutation.properties, ...atPlayhead.map((k) => k.properties)]; if (mutation.x != null || mutation.y != null) written.push({ x: mutation.x, y: mutation.y }); for (const source of sources) { if (source && (source.x != null || source.y != null)) diff --git a/packages/studio/src/hooks/gsapResizeElementSize.test.tsx b/packages/studio/src/hooks/gsapResizeElementSize.test.tsx index 6ff6686385..712f3ac876 100644 --- a/packages/studio/src/hooks/gsapResizeElementSize.test.tsx +++ b/packages/studio/src/hooks/gsapResizeElementSize.test.tsx @@ -37,6 +37,7 @@ const widthTween = { position: 0, resolvedStart: 0, duration: 4, + ease: "none", } as unknown as GsapAnimation; function mount(animations: GsapAnimation[]) { diff --git a/packages/studio/src/hooks/gsapResizeGeometrySweep.test.ts b/packages/studio/src/hooks/gsapResizeGeometrySweep.test.ts index 2058596976..ef0cf64d23 100644 --- a/packages/studio/src/hooks/gsapResizeGeometrySweep.test.ts +++ b/packages/studio/src/hooks/gsapResizeGeometrySweep.test.ts @@ -67,7 +67,7 @@ function tween(id: string, properties: Props, duration: number): GsapAnimation { position: 0, resolvedStart: 0, duration, - ...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : {}), + ...(duration === 0 ? { extras: { immediateRender: "__raw:true" } } : { ease: "none" }), } as unknown as GsapAnimation; } diff --git a/packages/studio/src/hooks/gsapResizeIntercept.test.ts b/packages/studio/src/hooks/gsapResizeIntercept.test.ts index 15c7a428b4..f56a6c320d 100644 --- a/packages/studio/src/hooks/gsapResizeIntercept.test.ts +++ b/packages/studio/src/hooks/gsapResizeIntercept.test.ts @@ -5,10 +5,13 @@ import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { computeCurrentPercentage } from "./gsapDragCommit"; import { tryGsapResizeIntercept } from "./gsapResizeIntercept"; +import { liveTween } from "./gsapParsedTween.test-helpers"; afterEach(() => { vi.restoreAllMocks(); usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }); + // One #clip per test: leftovers would make every tween on it look shared. + document.body.innerHTML = ""; }); /** @@ -36,10 +39,18 @@ function fakeIframe(el: HTMLElement, gsapValues: Record) { // The element's OPACITY intro tween lives on the timeline: unfiltered // capture would pick `opacity` up via the other-tween sweep. const opacityIntro = { targets: () => [el], vars: { opacity: 0, duration: 0.8 } }; + const scaleIntro = liveTween( + el, + { start: 0.2, duration: 0.8, vars: { scale: 0.9 }, ends: { scaleX: [0.9, 1] } }, + { from: true }, + ); return { contentWindow: { - __timelines: { main: { getChildren: () => [opacityIntro] } }, - gsap: { getProperty: (_el: Element, prop: string) => gsapValues[prop] ?? 0 }, + __timelines: { main: { getChildren: () => [opacityIntro, scaleIntro] } }, + gsap: { + getProperty: (_el: Element, prop: string) => gsapValues[prop] ?? 0, + defaults: () => ({ ease: "power1.out" }), + }, }, contentDocument: document, } as unknown as HTMLIFrameElement; @@ -289,24 +300,26 @@ async function runResize( return committed; } -it("scale-route resize converts via the group filter and commits scale, not width/height", async () => { +it("scale-route resize keyframes scale through the group filter, not width/height", async () => { const el = makeGradedElement(); const iframe = fakeIframe(el, { scale: 1, scaleX: 1, scaleY: 1, opacity: 0, rotation: 0 }); // uniform: 800/640 === 450/360 const committed = await runResize(el, iframe, { width: 800, height: 450 }); - const convert = committed.find((m) => m.type === "convert-to-keyframes"); - expect(convert).toBeDefined(); - const fromValues = convert!.resolvedFromValues as Record; - // Group filter: the opacity intro tween must NOT leak into the conversion. - expect(fromValues).not.toHaveProperty("opacity"); - expect(fromValues).toHaveProperty("scale"); + const keyframes = committed.find((m) => m.type === "replace-with-keyframes")?.keyframes as + | Array<{ properties: Record }> + | undefined; + // Group filter: the opacity intro tween must NOT leak into the scale keyframes. + expect(keyframes?.some((k) => "opacity" in k.properties)).toBe(false); + expect(keyframes?.some((k) => "scale" in k.properties)).toBe(true); // Every committed property is scale-group — the resize never writes // width/height for a scale-driven element (the double-apply bug class). const allProps = committed.flatMap((m) => [ ...Object.keys((m.properties as Record) ?? {}), - ...Object.keys((m.resolvedFromValues as Record) ?? {}), + ...((m.keyframes as Array<{ properties: object }>) ?? []).flatMap((k) => + Object.keys(k.properties), + ), ]); expect(allProps).not.toContain("width"); expect(allProps).not.toContain("height"); diff --git a/packages/studio/src/hooks/gsapResizeIntercept.ts b/packages/studio/src/hooks/gsapResizeIntercept.ts index e4adbc5348..dfbc6fa300 100644 --- a/packages/studio/src/hooks/gsapResizeIntercept.ts +++ b/packages/studio/src/hooks/gsapResizeIntercept.ts @@ -14,44 +14,34 @@ import { } from "../components/editor/manualEditsTypes"; import { setElementGsapPosition, setElementGsapScale } from "../utils/elementGsap"; import { usePlayerStore } from "../player/store/playerStore"; -import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders"; +import { readGsapProperty } from "./gsapRuntimeReaders"; import { commitStaticGsapPosition, commitStaticGsapSize, - commitKeyframedSizeFromResize, computeCurrentPercentage, findExistingPositionWrite, findSizeSetAnimation, - materializeIfDynamic, } from "./gsapDragCommit"; import type { GsapDragCommitCallbacks } from "./gsapDragCommit"; import { computeDraggedGsapPosition } from "./draggedGsapPosition"; import { pickClosestToPlayhead, readGsapPositionFromIframe } from "./gsapPositionDetection"; import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit"; import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit"; -import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; +import { resolveTweenDuration } from "../utils/globalTimeCompiler"; import { isInstantHold, selectorFromSelection, writeTargetSelector } from "./gsapShared"; import { roundTo3, roundToLayoutPx } from "../utils/rounding"; import { resolveGroupTween } from "./gsapRuntimeBridge"; import { logResize } from "../utils/resizeDebug"; -import { animationWritesAnyProperty, type GsapEditOutcome } from "./gsapEditOutcome"; +import { + animationWritesAnyProperty, + assertGsapEditPersisted, + type GsapEditOutcome, +} from "./gsapEditOutcome"; +import { commitValueAtPlayhead } from "./gsapValueAtPlayhead"; import { preflightGsapResizeIntercept, resizeRoute } from "./gsapResizePreflight"; -const IDENTITY_ONE_PROPS = new Set(["opacity", "autoAlpha", "scale", "scaleX", "scaleY"]); const SIZE_PROPS = new Set(["width", "height"]); -/** 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; -} - /** * The element's box before the resize draft ran, in CSS pixels. * @@ -72,6 +62,13 @@ function originalBoxSize( return Number.isFinite(inline) && inline > 0 ? inline : null; } +/** The box's width and height before the draft, for a size tween that animates only one. */ +function preGestureBoxSize(el: HTMLElement): Record { + const width = originalBoxSize(el, STUDIO_ORIGINAL_BOX_WIDTH_ATTR, "width"); + const height = originalBoxSize(el, STUDIO_ORIGINAL_BOX_HEIGHT_ATTR, "height"); + return { ...(width != null && { width }), ...(height != null && { height }) }; +} + /** * Whether this tween already states scale as `scaleX`/`scaleY`. * @@ -120,7 +117,7 @@ export async function tryGsapResizeIntercept( postSplitFetch, ); - let anim = + const anim = resolved?.anim && animationWritesAnyProperty(resolved.anim, resizeProperties) ? resolved.anim : null; @@ -166,15 +163,14 @@ export async function tryGsapResizeIntercept( ); if (animatedTween) { logResize("intercept-route", { route: "keyframed-size", tweenId: animatedTween.id }); - const handled = await commitKeyframedSizeFromResize( + return commitValueAtPlayhead( selection, - size, - sel, - sizeSet, animatedTween, + { width: roundToLayoutPx(size.width), height: roundToLayoutPx(size.height) }, + iframe, { commitMutation, fetchAnimations: fetchFallbackAnimations }, + { label: "Resize", backfill: preGestureBoxSize(selection.element) }, ); - if (handled) return { status: "persisted" }; } } @@ -196,16 +192,10 @@ export async function tryGsapResizeIntercept( const { activeKeyframePct, setActiveKeyframePct } = usePlayerStore.getState(); const pct = activeKeyframePct ?? computeCurrentPercentage(selection, anim); - if (activeKeyframePct != null) setActiveKeyframePct(null); const selector = selectorFromSelection(selection); - // Scope every capture to the resize group — same contract as the rotation - // intercept. Unfiltered, an opacity-touching intro tween on the element - // would ride into resize conversions/backfills (the Fix-2 bake class). - const runtimeProps = selector - ? readAllAnimatedProperties(iframe, selector, anim, resizeGroup) - : {}; let resizeProps: Record; + let resizeBackfill: Record; let scaleDraftEl: HTMLElement | null = null; let scaleDraftDropPoint: { x: number; y: number } | null = null; /** The scale this commit is putting on the element, for the finalize step. */ @@ -262,6 +252,7 @@ export async function tryGsapResizeIntercept( resizeProps = useScaleLonghands ? { scaleX: newScaleX, scaleY: newScaleY } : { scale: newScaleX }; + resizeBackfill = { scaleX: liveScaleX, scaleY: liveScaleY }; logResize("intercept-route", { route: "scale-tween", cssW, @@ -295,6 +286,7 @@ export async function tryGsapResizeIntercept( width: roundToLayoutPx(size.width), height: roundToLayoutPx(size.height), }; + resizeBackfill = preGestureBoxSize(selection.element); } // Finalize a scale-route commit: tear down the gesture's inline width/height // draft (leaving it applied compounds with the committed scale — the element @@ -404,10 +396,12 @@ export async function tryGsapResizeIntercept( ); if (positionTween) { logResize("scale-finalize", { route: "position-keyframe", tweenId: positionTween.id }); - await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, selector, { - commitMutation, - fetchAnimations: fetchFallbackAnimations, - }); + assertGsapEditPersisted( + await commitGsapPositionFromDrag(selection, positionTween, delta, base, iframe, { + commitMutation, + fetchAnimations: fetchFallbackAnimations, + }), + ); return true; } const existingSet = findExistingPositionWrite(currentAnimations, selector, selection.element); @@ -435,134 +429,15 @@ export async function tryGsapResizeIntercept( return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() }; } - const ct = usePlayerStore.getState().currentTime; - const ts = resolveTweenStart(anim); - const td = tweenDuration; - 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) { - // fallow-ignore-next-line code-duplication - 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, resizeGroup) - : undefined; - await commitMutation( - selection, - { type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues }, - { label: "Convert to keyframes for resize" }, - ); - if (fetchFallbackAnimations) { - const fresh = await fetchFallbackAnimations(); - const refreshed = fresh.find( - (a) => a.targetSelector === anim!.targetSelector && a.keyframes, - ); - if (refreshed) anim = refreshed; - } - } - } - - // A NON-uniform scale must also take the full-rewrite path: it mixes - // scaleX/scaleY into a tween whose existing keyframes may carry the uniform - // `scale` shorthand, and GSAP's percentage keyframes animate each property - // name independently — a shorthand/longhand mix would leave the old `scale` - // sub-tween running against the new scaleX/scaleY. The rewrite below - // normalizes every keyframe to the longhands. For an in-range resize the - // min/max window math below degenerates to the tween's own start/duration, - // so timing is unchanged. - if ((outsideRange || useScaleLonghands) && 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 }; - // Normalize the uniform `scale` shorthand to longhands when this commit - // writes scaleX/scaleY, so the tween never mixes the two forms. - if (nonUniformScale && "scale" in props) { - const uniform = props.scale; - if (typeof uniform === "number") { - props.scaleX = uniform; - props.scaleY = uniform; - } - delete props.scale; - } - // 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; - // An in-range rewrite can land on an existing keyframe's percentage — - // merge into it instead of emitting a duplicate step. - const collidingKf = remapped.find((kf) => Math.abs(kf.percentage - targetPct) < 0.05); - if (collidingKf) Object.assign(collidingKf.properties, resizeProps); - else 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: roundTo3(newStart), - duration: roundTo3(newDuration), - keyframes: remapped, - }, - { - label: outsideRange - ? `Resize (extended to ${ct.toFixed(2)}s)` - : `Resize (keyframe ${Math.round(((ct - newStart) / newDuration) * 1000) / 10}%)`, - softReload: true, - }, - ); - return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() }; - } - - 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( + const written = await commitValueAtPlayhead( selection, - { - type: "add-keyframe", - animationId: anim.id, - percentage: pct, - properties: resizeProps, - backfillDefaults, - }, - { label: `Resize (keyframe ${pct}%)`, softReload: true }, + anim, + resizeProps, + iframe, + { commitMutation, fetchAnimations: fetchFallbackAnimations }, + { label: "Resize", backfill: resizeBackfill }, ); + if (written.status !== "persisted") return written; return { status: "persisted", ownsDragOffset: await finalizeScaleResizeCommit() }; } diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts index d6f206b51c..f436e7ae95 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts @@ -23,7 +23,7 @@ function fakeIframe(elId: string, children: unknown[]): HTMLIFrameElement { return { contentWindow: { __timelines: { "index.html": timeline }, - gsap: { getProperty: () => 0 }, + gsap: { getProperty: () => 0, defaults: () => ({ ease: "power1.out" }) }, }, contentDocument: { querySelector: (sel: string) => (sel === `#${elId}` ? el : null) }, } as unknown as HTMLIFrameElement; @@ -451,7 +451,8 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => { fakeIframe("puck-b", []), commitMutation, ); - return { handled, types: commitMutation.mock.calls.map(([, mutation]) => mutation.type) }; + const mutations = commitMutation.mock.calls.map(([, mutation]) => mutation); + return { handled, mutations, types: mutations.map((mutation) => mutation.type) }; } it("shifts the whole tween instead of adding a keyframe when the toggle is off", async () => { @@ -461,10 +462,40 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => { expect(types).not.toContain("add-keyframe"); }); - it("still adds/updates a keyframe at the playhead when the toggle is on (default)", async () => { - const { handled, types } = await runAutoKeyframeDrag(true); + it("shifts a linear flat tween as keyframes that stay linear", async () => { + usePlayerStore.setState({ autoKeyframeEnabled: false, currentTime: 1 }); + const commitMutation = vi.fn(); + const linear = { ...stalePositionAnim, ease: "none" } as GsapAnimation; + const live = { + targets: () => [{ id: "puck-b" }], + vars: { x: -180, y: -60, ease: "none" }, + duration: () => 2, + startTime: () => 1, + }; + await tryGsapDragIntercept( + selection, + { x: -50, y: 0 }, + [linear], + fakeIframe("puck-b", [live]), + commitMutation, + ); + const [mutation] = commitMutation.mock.calls.map(([, m]) => m); + expect(mutation).toMatchObject({ type: "replace-with-keyframes", easeEach: "none" }); + expect(mutation).not.toHaveProperty("ease", "none"); + }); + + it("still changes only the keyframe at the playhead when the toggle is on (default)", async () => { + const { handled, mutations } = await runAutoKeyframeDrag(true); expect(handled).toEqual({ status: "persisted" }); - expect(types).not.toContain("replace-with-keyframes"); + expect(mutations).toEqual([ + expect.objectContaining({ + type: "replace-with-keyframes", + keyframes: [ + { percentage: 0, properties: { x: 0, y: 0 } }, + { percentage: 100, properties: { x: -50, y: 0 } }, + ], + }), + ]); }); }); diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index fb00fe2f71..265f9cbfb1 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -15,6 +15,7 @@ import { usePlayerStore } from "../player/store/playerStore"; import { readAllAnimatedProperties, readGsapProperty } from "./gsapRuntimeReaders"; import { commitGsapPositionFromDrag } from "./gsapDragPositionCommit"; +import { commitValueAtPlayhead } from "./gsapValueAtPlayhead"; import { commitStaticGsapPosition, commitStaticGsapRotation, @@ -22,7 +23,6 @@ import { computeCurrentPercentage, findExistingPositionWrite, findRotationSetAnimation, - materializeIfDynamic, } from "./gsapDragCommit"; import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit"; import { isGestureTransactionCommit } from "./gestureTransaction"; @@ -250,7 +250,8 @@ export async function tryGsapDragIntercept( if (animations.filter(isPosWrite).length > 1 && fetchFallbackAnimations) { const fresh = await fetchFallbackAnimations(); const dupes = fresh.filter(isPosWrite); - if (dupes.length > 1) { + // Real tweens one after another are a motion, not a conflict: only holds can fight. + if (dupes.length > 1 && dupes.filter((a) => !isInstantHold(a)).length <= 1) { const keeper = dupes.find((a) => a.keyframes) ?? dupes.find((a) => (a.duration ?? 0) > 0) ?? dupes[0]!; await commitMutation( @@ -329,12 +330,16 @@ export async function tryGsapDragIntercept( // current ID and avoid a stale-ID remove that creates duplicate tweens. if (fetchFallbackAnimations) { const fresh = await fetchFallbackAnimations(); - const freshMatch = fresh.find( - (a) => - a.targetSelector === posAnim!.targetSelector && - a.propertyGroup === posAnim!.propertyGroup && - isXYPositionWrite(a) === isXYPositionWrite(posAnim!), - ); + const freshMatch = + fresh.find((a) => a.id === posAnim!.id) ?? + pickClosestToPlayhead( + fresh.filter( + (a) => + a.targetSelector === posAnim!.targetSelector && + a.propertyGroup === posAnim!.propertyGroup && + isXYPositionWrite(a) === isXYPositionWrite(posAnim!), + ), + ); if (freshMatch && freshMatch.id !== posAnim.id) { posAnim = freshMatch; } @@ -349,7 +354,7 @@ export async function tryGsapDragIntercept( if (options?.altKey || !autoKeyframeEnabled) { await commitWholePathOffset(selection, posAnim, offset, gsapPos, iframe, selector, cbs); } else { - await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, cbs); + return commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, cbs); } return { status: "persisted" }; } @@ -428,8 +433,9 @@ export async function tryGsapRotationIntercept( ? resolved.anim : null; if (!anim) { - anim = - workingAnimations.find((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)) ?? null; + anim = pickClosestToPlayhead( + workingAnimations.filter((a) => animationWritesAnyProperty(a, ROTATION_CHANNEL_SET)), + ); } // `angle` is the ABSOLUTE target rotation resolved by the gesture (gsap base + @@ -469,42 +475,14 @@ export async function tryGsapRotationIntercept( return { status: "persisted" }; } - // fallow-ignore-next-line code-duplication - 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, "rotation") - : undefined; - await commitMutation( - selection, - { type: "convert-to-keyframes", animationId: anim.id, resolvedFromValues }, - { label: "Convert to keyframes for rotation", skipReload: true }, - ); - } - - const runtimeProps = readAllAnimatedProperties(iframe, selector, anim, "rotation"); - - const backfillDefaults: Record = { ...runtimeProps }; - if (!("rotation" in runtimeProps)) { - backfillDefaults.rotation = readGsapProperty(iframe, selector, "rotation") ?? 0; - } - - const properties = { ...runtimeProps, rotation: newRotation }; - - await commitMutation( + return commitValueAtPlayhead( selection, - { - type: "add-keyframe", - animationId: anim.id, - percentage: pct, - properties, - backfillDefaults, - }, - { label: `Rotate (keyframe ${pct}%)`, softReload: true }, + anim, + { rotation: newRotation }, + iframe, + { commitMutation, fetchAnimations: fetchFallbackAnimations }, + { label: "Rotate" }, ); - return { status: "persisted" }; } export { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes"; diff --git a/packages/studio/src/hooks/gsapShared.ts b/packages/studio/src/hooks/gsapShared.ts index 34a7ec5859..e8c7d56702 100644 --- a/packages/studio/src/hooks/gsapShared.ts +++ b/packages/studio/src/hooks/gsapShared.ts @@ -34,6 +34,13 @@ export const PROPERTY_DEFAULTS: Record = { height: 100, }; +/** A tween's eases for re-writing it as keyframes: a flat tween's ease must become easeEach, + * since GSAP eases each percentage segment power1.inOut whatever the tween-level ease says. */ +export function keyframeEases(anim: GsapAnimation): { ease?: string; easeEach?: string } { + if (!anim.keyframes) return { easeEach: anim.ease }; + return { ease: anim.ease, easeEach: anim.keyframes.easeEach }; +} + /** * A timeline write that applies an instantaneous value and then holds it. * `set()` is always a hold; authored `to()` / `fromTo()` tweens are holds only diff --git a/packages/studio/src/hooks/gsapValueAtPlayhead.test.ts b/packages/studio/src/hooks/gsapValueAtPlayhead.test.ts new file mode 100644 index 0000000000..6a06cfd642 --- /dev/null +++ b/packages/studio/src/hooks/gsapValueAtPlayhead.test.ts @@ -0,0 +1,445 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { usePlayerStore } from "../player/store/playerStore"; +import { GSAP_EDIT_BLOCK_COPY } from "./gsapEditOutcome"; +import { liveTween, previewWith, tween } from "./gsapParsedTween.test-helpers"; +import { tryGsapResizeIntercept } from "./gsapResizeIntercept"; +import { tryGsapDragIntercept, tryGsapRotationIntercept } from "./gsapRuntimeBridge"; +import { planValueAtPlayhead, type PlayheadEdit } from "./gsapValueAtPlayhead"; + +let el: HTMLElement; +let selection: DomEditSelection; +beforeEach(() => { + el = document.createElement("div"); + el.id = "box"; + document.body.append(el); + selection = { id: "box", selector: "#box", element: el } as DomEditSelection; +}); +afterEach(() => { + el.remove(); + usePlayerStore.setState({ currentTime: 0, activeKeyframePct: null }); +}); + +/** Drags `#box` by `dx` from a pre-gesture GSAP position of `base`, at `time`. */ +async function drag( + anims: GsapAnimation[], + live: unknown[], + { + time, + base, + dx, + values = {}, + }: { time: number; base: [number, number]; dx: number; values?: Record }, +) { + usePlayerStore.setState({ currentTime: time }); + el.setAttribute("data-hf-drag-gsap-base-x", String(base[0])); + el.setAttribute("data-hf-drag-gsap-base-y", String(base[1])); + const commitMutation = vi.fn(); + const outcome = await tryGsapDragIntercept( + selection, + { x: dx, y: 0 }, + anims, + previewWith(el, live, values), + commitMutation, + async () => anims, + ); + return { outcome, mutations: commitMutation.mock.calls.map((call) => call[1]) }; +} + +const fromX = tween({ + id: "#box-from-0-position", + method: "from", + properties: { x: -60 }, + resolvedStart: 0, + duration: 2, + ease: "none", +}); +const fromXLive = (end: number) => + liveTween( + el, + { start: 0, duration: 2, vars: { x: -60, ease: "none" }, ends: { x: [-60, end] } }, + { from: true }, + ); + +describe("a move on a GSAP-animated layer, at the playhead", () => { + it("adds a keyframe inside a from() tween and keeps its end where GSAP parsed it", async () => { + const { outcome, mutations } = await drag([fromX], [fromXLive(40)], { + time: 1, + base: [-10, 0], + dx: 30, + }); + expect(outcome).toEqual({ status: "persisted" }); + expect(mutations).toEqual([ + expect.objectContaining({ + type: "replace-with-keyframes", + animationId: "#box-from-0-position", + position: 0, + duration: 2, + easeEach: "none", + keyframes: [ + { percentage: 0, properties: { x: -60, y: 0 } }, + { percentage: 50, properties: { x: 20, y: 0 } }, + { percentage: 100, properties: { x: 40, y: 0 } }, + ], + }), + ]); + }); + + it("writes the end GSAP parsed from the file, not the dragged element's live value", async () => { + const { mutations } = await drag([fromX], [fromXLive(40)], { + time: 1, + base: [-10, 0], + dx: 30, + values: { x: 999 }, + }); + expect(mutations[0].keyframes.at(-1)).toEqual({ percentage: 100, properties: { x: 40, y: 0 } }); + }); + + it("extends a to() tween back to the playhead, holding the start GSAP folded from CSS translate", async () => { + const toX = tween({ + id: "#box-to-2000-position", + method: "to", + properties: { x: 60 }, + resolvedStart: 2, + duration: 1, + ease: "none", + }); + // `translate: 40px 30px` on the element: GSAP parses x from 40, not 0. + const live = liveTween(el, { start: 2, duration: 1, vars: { x: 60 }, ends: { x: [40, 60] } }); + const { mutations } = await drag([toX], [live], { time: 1, base: [40, 0], dx: -20 }); + expect(mutations).toEqual([ + expect.objectContaining({ + animationId: "#box-to-2000-position", + position: 1, + duration: 2, + easeEach: "none", + keyframes: [ + { percentage: 0, properties: { x: 20, y: 0 } }, + { percentage: 50, properties: { x: 40, y: 0 } }, + { percentage: 100, properties: { x: 60, y: 0 } }, + ], + }), + ]); + }); + + it("refuses, with the Code tab message, when GSAP has not parsed the tween's end yet", async () => { + const unparsed = liveTween(el, { start: 0, duration: 2, vars: { x: -60 } }, { from: true }); + const { outcome, mutations } = await drag([fromX], [unparsed], { + time: 1, + base: [-10, 0], + dx: 30, + }); + expect(outcome).toEqual({ + status: "blocked", + reason: "keyframes-uneditable", + detail: "implicit-end-unknown", + }); + expect(mutations).toEqual([]); + expect(GSAP_EDIT_BLOCK_COPY["keyframes-uneditable"]).toContain("Code tab"); + }); + + it("adds a keyframe between two steps of a keyframes array", async () => { + const keys = tween({ + id: "#box-to-0-position", + method: "to", + properties: {}, + resolvedStart: 0, + duration: 3, + keyframes: { + format: "object-array", + keyframes: [ + // The parse rounds the step to 66.7%; GSAP times it at 2 s of 3. + { percentage: 66.7, properties: { x: 60 } }, + { percentage: 100, properties: { x: 120 } }, + ], + }, + }); + const steps = [ + { startTime: () => 0, duration: () => 2 }, + { startTime: () => 2, duration: () => 1 }, + ]; + const live = liveTween( + el, + { start: 0, duration: 3, vars: { keyframes: [] } }, + { parts: steps }, + ); + const { mutations } = await drag([keys], [live], { time: 1, base: [30, 0], dx: 10 }); + expect(mutations.map((m) => m.type)).toEqual(["replace-with-keyframes"]); + // An array step eases linearly unless it says otherwise; percentage keyframes would not. + expect(mutations[0].keyframes).toEqual([ + { percentage: 33.333, properties: { x: 40, y: 0 }, ease: "none" }, + { percentage: 66.667, properties: { x: 60, y: 0 }, ease: "none" }, + { percentage: 100, properties: { x: 120, y: 0 }, ease: "none" }, + ]); + }); + + it("never writes x and y into a tween that only animates size", async () => { + const size = tween({ + id: "#box-to-0-size", + propertyGroup: "size", + method: "to", + properties: {}, + resolvedStart: 0, + duration: 2, + keyframes: { + format: "percentage", + keyframes: [{ percentage: 100, properties: { width: 300 } }], + }, + }); + const live = liveTween(el, { + start: 0, + duration: 2, + vars: { keyframes: { "100%": { width: 300 } } }, + }); + const { mutations } = await drag([size], [live], { time: 1, base: [0, 0], dx: 10 }); + expect(mutations.some((m) => m.animationId === "#box-to-0-size")).toBe(false); + }); +}); + +describe("where two tweens meet at the playhead", () => { + it("edits the one whose keyframe the file states there, not the one listed first", async () => { + const fromTo = tween({ + id: "#box-fromTo-2000-position", + method: "fromTo", + fromProperties: { x: 0 }, + properties: { x: 60 }, + resolvedStart: 2, + duration: 1, + ease: "none", + }); + const live = liveTween(el, { start: 2, duration: 1, vars: { x: 60 }, ends: { x: [0, 60] } }); + const { mutations } = await drag([fromX, fromTo], [fromXLive(40), live], { + time: 2, + base: [0, 0], + dx: 25, + }); + expect(mutations).toEqual([ + expect.objectContaining({ + animationId: "#box-fromTo-2000-position", + easeEach: "none", + keyframes: [ + { percentage: 0, properties: { x: 25, y: 0 } }, + { percentage: 100, properties: { x: 60, y: 0 } }, + ], + }), + ]); + }); +}); + +describe("a resize on a layer whose size GSAP animates, at the playhead", () => { + it("adds a size keyframe inside the tween at the playhead", async () => { + const grow = (id: string, start: number, duration: number, width: number, height: number) => + tween({ + id, + propertyGroup: "size", + method: "to", + properties: { width, height }, + resolvedStart: start, + duration, + ease: "none", + }); + const first = grow("#box-to-0-size", 0, 2, 300, 200); + const second = grow("#box-to-2000-size", 2, 1, 360, 240); + const live = [first, second].map((a) => + liveTween(el, { start: a.resolvedStart!, duration: a.duration!, vars: a.properties }), + ); + usePlayerStore.setState({ currentTime: 1 }); + const commitMutation = vi.fn(); + await tryGsapResizeIntercept( + selection, + { width: 330, height: 210 }, + [first, second], + previewWith(el, live), + commitMutation, + ); + expect(commitMutation.mock.calls.map((call) => call[1])).toEqual([ + expect.objectContaining({ + type: "replace-with-keyframes", + animationId: "#box-to-0-size", + easeEach: "none", + keyframes: [ + { percentage: 50, properties: { width: 330, height: 210 } }, + { percentage: 100, properties: { width: 300, height: 200 } }, + ], + }), + ]); + }); +}); + +describe("a resize on a layer whose scale GSAP animates, at the playhead", () => { + it("adds a scale keyframe inside the tween at the playhead and leaves the next tween alone", async () => { + const grow = (id: string, start: number, duration: number, scale: number) => + tween({ + id, + propertyGroup: "scale", + method: "to", + properties: { scale }, + resolvedStart: start, + duration, + ease: "none", + }); + const first = grow("#box-to-0-scale", 0, 2, 1.25); + const second = grow("#box-to-2000-scale", 2, 1, 1.5); + el.setAttribute("data-hf-studio-original-box-width", "240"); + el.setAttribute("data-hf-studio-original-box-height", "160"); + const live = [first, second].map((a) => + liveTween(el, { start: a.resolvedStart!, duration: a.duration!, vars: a.properties }), + ); + usePlayerStore.setState({ currentTime: 1 }); + const commitMutation = vi.fn(); + // Dropped at 1.5x the box with a live scale of 1.125, the playhead's value of the first tween. + await tryGsapResizeIntercept( + selection, + { width: 320, height: 213.333 }, + [first, second], + previewWith(el, live, { scaleX: 1.125, scaleY: 1.125 }), + commitMutation, + ); + const writes = commitMutation.mock.calls.map((call) => call[1]); + expect(writes[0]).toMatchObject({ + type: "replace-with-keyframes", + animationId: "#box-to-0-scale", + easeEach: "none", + keyframes: [ + { percentage: 50, properties: { scale: 1.5 } }, + { percentage: 100, properties: { scale: 1.25 } }, + ], + }); + expect(writes.some((m) => m.animationId === "#box-to-2000-scale")).toBe(false); + }); +}); + +describe("a rotate on a GSAP-animated layer, at the playhead", () => { + it("changes the keyframe two flat tweens meet at, on the tween that states it", async () => { + const spin = (id: string, start: number, duration: number, rotation: number) => + tween({ + id, + propertyGroup: "rotation", + method: "to", + properties: { rotation }, + resolvedStart: start, + duration, + ease: "none", + }); + const first = spin("#box-to-0-rotation", 0, 2, 20); + const second = spin("#box-to-2000-rotation", 2, 1, 40); + usePlayerStore.setState({ currentTime: 2 }); + const commitMutation = vi.fn(); + const live = [first, second].map((a) => + liveTween(el, { start: a.resolvedStart!, duration: a.duration!, vars: a.properties }), + ); + await tryGsapRotationIntercept( + selection, + 25, + [first, second], + previewWith(el, live), + commitMutation, + ); + expect(commitMutation.mock.calls.map((call) => call[1])).toEqual([ + expect.objectContaining({ + type: "replace-with-keyframes", + animationId: "#box-to-0-rotation", + position: 0, + duration: 2, + easeEach: "none", + keyframes: [{ percentage: 100, properties: { rotation: 25 } }], + }), + ]); + }); +}); + +describe("planValueAtPlayhead", () => { + const plan = (edit: Partial) => + planValueAtPlayhead({ + anim: tween({ method: "to", properties: {}, resolvedStart: 0, duration: 4 }), + at: { time: 2 }, + values: {}, + implicitEndValue: () => null, + ...edit, + }); + const kf = ( + keyframes: Array<{ percentage: number; properties: Record }>, + easeEach?: string, + ) => + tween({ + method: "to", + properties: {}, + resolvedStart: 0, + duration: 4, + keyframes: { format: "percentage", keyframes, ...(easeEach && { easeEach }) }, + }); + + it("changes the keyframe under the playhead and no other", () => { + const result = plan({ + anim: kf([ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 10 } }, + { percentage: 100, properties: { x: 20 } }, + ]), + values: { x: 15 }, + }); + expect(result.ok && result.mutation.keyframes.map((k) => k.properties.x)).toEqual([0, 15, 20]); + }); + + it("eases the new segment like the one it splits", () => { + const result = plan({ + anim: kf([{ percentage: 100, properties: { x: 20 } }], "power2.in"), + at: { time: 1 }, + values: { x: 5 }, + }); + expect(result.ok && result.mutation).toMatchObject({ + easeEach: "power2.in", + keyframes: [ + { percentage: 25, properties: { x: 5 } }, + { percentage: 100, properties: { x: 20 } }, + ], + }); + }); + + it("extends past the end and keeps the authored end where it was", () => { + const result = plan({ + anim: tween({ + method: "to", + properties: { width: 300 }, + resolvedStart: 0, + duration: 2, + ease: "none", + }), + at: { time: 3 }, + values: { width: 400 }, + }); + expect(result.ok && result.mutation).toMatchObject({ + position: 0, + duration: 3, + easeEach: "none", + keyframes: [ + { percentage: 66.667, properties: { width: 300 } }, + { percentage: 100, properties: { width: 400 } }, + ], + }); + }); + + it("splits a uniform scale into longhands for a per-axis edit", () => { + const result = plan({ + anim: tween({ + method: "to", + properties: { scale: 1.25 }, + resolvedStart: 0, + duration: 4, + ease: "none", + }), + values: { scaleX: 1.5, scaleY: 1.1 }, + }); + expect(result.ok && result.mutation.keyframes).toEqual([ + { percentage: 50, properties: { scaleX: 1.5, scaleY: 1.1 } }, + { percentage: 100, properties: { scaleX: 1.25, scaleY: 1.25 } }, + ]); + }); + + it("refuses keyframes eased as a whole, whose segment curves it cannot keep", () => { + const anim = { ...kf([{ percentage: 100, properties: { x: 20 } }]), ease: "power2.out" }; + expect(plan({ anim, values: { x: 5 } })).toEqual({ ok: false, reason: "eased-keyframes" }); + }); +}); diff --git a/packages/studio/src/hooks/gsapValueAtPlayhead.ts b/packages/studio/src/hooks/gsapValueAtPlayhead.ts new file mode 100644 index 0000000000..1cd8bd8634 --- /dev/null +++ b/packages/studio/src/hooks/gsapValueAtPlayhead.ts @@ -0,0 +1,266 @@ +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { usePlayerStore } from "../player/store/playerStore"; +import { resolveTweenDuration, resolveTweenStart } from "../utils/globalTimeCompiler"; +import { roundTo3 } from "../utils/rounding"; +import { + materializeIfDynamic, + parkPlayheadOnKeyframe, + type GsapDragCommitCallbacks, +} from "./gsapDragCommit"; +import type { GsapEditOutcome, PlayheadEditRefusal } from "./gsapEditOutcome"; +import { + findParsedTween, + parsedImplicitEndValue, + parsedTweenEase, + withExactStepTimes, +} from "./gsapParsedTween"; +import { KEYFRAME_PCT_MATCH } from "./gsapShared"; +import { tweenReach } from "./gsapTweenReach"; + +type Props = Record; +interface Keyframe { + percentage: number; + properties: Props; + ease?: string; + auto?: boolean; +} + +/** A tween's un-animated value of `prop` at its start or end, from GSAP's own parse; null when unknown. */ +export type ImplicitEndValue = (prop: string, end: "start" | "end") => number | null; + +export interface PlayheadEdit { + anim: GsapAnimation; + /** The playhead's composition time, or a keyframe the user selected in the lane. */ + at: { time: number } | { percentage: number }; + /** Every channel here must already be animated by `anim`. */ + values: Record; + implicitEndValue: ImplicitEndValue; + /** The flat tween's ease as GSAP resolved it, for a tween that authors none. */ + parsedEase?: string | null; + /** A channel of the edit the tween does not animate yet, held at this value on its keyframes. */ + backfill?: Record; +} + +export type PlayheadEditPlan = + | { + ok: true; + mutation: { + type: "replace-with-keyframes"; + animationId: string; + targetSelector: string; + position: number; + duration: number; + keyframes: Keyframe[]; + easeEach?: string; + }; + } + | { ok: false; reason: PlayheadEditRefusal }; + +// GSAP 3.14 defaults: a percentage keyframe eases its segment power1.inOut; an array step is linear. +const PERCENTAGE_SEGMENT_EASE = "power1.inOut"; +const ARRAY_STEP_EASE = "none"; +const roundPct = (pct: number) => Math.round(pct * 1000) / 1000; +const isLinear = (ease: string | undefined) => !ease || ease === "none" || ease === "linear"; + +interface Normalized { + keyframes: Keyframe[]; + easeEach?: string; +} + +function refuse(reason: PlayheadEditRefusal): { ok: false; reason: PlayheadEditRefusal } { + return { ok: false, reason }; +} + +// fallow-ignore-next-line complexity +function normalize(edit: PlayheadEdit): Normalized | { reason: PlayheadEditRefusal } { + const { anim } = edit; + const data = anim.keyframes; + if (data) { + if (!isLinear(anim.ease) || !isLinear(data.ease)) return { reason: "eased-keyframes" }; + if (data.format === "simple-array") return { reason: "simple-array-keyframes" }; + const arrayStep = data.format === "object-array"; + return { + keyframes: data.keyframes.map((kf) => ({ + ...kf, + properties: { ...kf.properties }, + ...(arrayStep ? { ease: kf.ease ?? ARRAY_STEP_EASE } : {}), + })), + ...(data.easeEach && !arrayStep ? { easeEach: data.easeEach } : {}), + }; + } + // The flat tween's ease becomes easeEach: a top-level ease would leave each segment power1.inOut. + const easeEach = anim.ease ?? edit.parsedEase; + if (!easeEach) return { reason: "unknown-ease" }; + const to = { percentage: 100, properties: { ...anim.properties } }; + if (anim.method === "to") return { keyframes: [to], easeEach }; + if (anim.method === "fromTo") { + return { keyframes: [{ percentage: 0, properties: { ...anim.fromProperties } }, to], easeEach }; + } + if (anim.method === "from") { + const end: Props = {}; + for (const prop of Object.keys(anim.properties)) { + const value = edit.implicitEndValue(prop, "end"); + if (value == null) return { reason: "implicit-end-unknown" }; + end[prop] = value; + } + return { + keyframes: [ + { percentage: 0, properties: { ...anim.properties } }, + { percentage: 100, properties: end }, + ], + easeEach, + }; + } + return { reason: "not-a-tween" }; +} + +function valueAt(keyframes: Keyframe[], prop: string, side: "first" | "last") { + const carrying = keyframes.filter((kf) => kf.properties[prop] != null); + const kf = side === "first" ? carrying[0] : carrying.at(-1); + return kf ? { percentage: kf.percentage, value: kf.properties[prop]! } : null; +} + +function upsert(keyframes: Keyframe[], percentage: number, properties: Props, ease?: string) { + const hit = keyframes.find((kf) => Math.abs(kf.percentage - percentage) <= KEYFRAME_PCT_MATCH); + if (hit) { + Object.assign(hit.properties, properties); + return hit; + } + const added = { percentage, properties, ...(ease ? { ease } : {}) }; + keyframes.push(added); + return added; +} + +/** Every channel the tween animates, held at its value at the tween's authored start or end. */ +function heldEnds( + norm: Normalized, + end: "start" | "end", + implicit: ImplicitEndValue, + backfilled: Record, +) { + const props = new Set(norm.keyframes.flatMap((kf) => Object.keys(kf.properties))); + const held: Props = {}; + for (const prop of props) { + const known = valueAt(norm.keyframes, prop, end === "start" ? "first" : "last"); + // A channel holds its last keyframe after it; only a start can be implicit. + const value = + known && (end === "end" || known.percentage <= 0) + ? known.value + : (backfilled[prop] ?? implicit(prop, "start")); + if (value == null) return null; + held[prop] = value; + } + return held; +} + +/** On a keyframe, change it; between two, add one at the playhead; outside the tween, add one there + * and keep the authored ends. Values come from the file's tween or GSAP's parse, never the DOM. */ +// fallow-ignore-next-line complexity +export function planValueAtPlayhead(edit: PlayheadEdit): PlayheadEditPlan { + const { anim, values } = edit; + if (anim.extras && Object.keys(anim.extras).length > 0) return refuse("tween-extras"); + const start = resolveTweenStart(anim); + const duration = resolveTweenDuration(anim); + if (start == null || !(duration > 0)) return refuse("no-timing"); + const norm = normalize(edit); + if ("reason" in norm) return refuse(norm.reason); + const keyframes = norm.keyframes; + // GSAP runs `scale` beside scaleX/scaleY and the longhands win, so a per-axis edit splits it. + if ("scaleX" in values || "scaleY" in values) { + for (const kf of keyframes) { + const { scale, ...rest } = kf.properties; + if (scale != null) kf.properties = { scaleX: scale, scaleY: scale, ...rest }; + } + } + const backfilled: Record = {}; + for (const [prop, value] of Object.entries(edit.backfill ?? {})) { + if (!(prop in values) || keyframes.some((kf) => kf.properties[prop] != null)) continue; + for (const kf of keyframes) kf.properties[prop] = value; + backfilled[prop] = value; + } + let position = start; + let span = duration; + + const pct = + "percentage" in edit.at + ? edit.at.percentage + : roundPct(((edit.at.time - start) / duration) * 100); + if (pct >= -KEYFRAME_PCT_MATCH && pct <= 100 + KEYFRAME_PCT_MATCH) { + const at = Math.min(100, Math.max(0, pct)); + const next = [...keyframes] + .sort((a, b) => a.percentage - b.percentage) + .find((kf) => kf.percentage > at + KEYFRAME_PCT_MATCH); + // The new keyframe splits next's segment, so it takes next's ease. + upsert(keyframes, at, { ...values }, next?.ease); + } else { + const time = (edit.at as { time: number }).time; + const before = time < start; + const held = heldEnds(norm, before ? "start" : "end", edit.implicitEndValue, backfilled); + if (!held) return refuse("implicit-end-unknown"); + position = before ? time : start; + span = before ? start + duration - time : time - start; + for (const kf of keyframes) { + kf.percentage = roundPct( + ((start + (kf.percentage / 100) * duration - position) / span) * 100, + ); + } + const oldEdge = roundPct((((before ? start : start + duration) - position) / span) * 100); + // The span the tween gains is new and linear; the authored segments keep their eases. + const linear = isLinear(norm.easeEach ?? PERCENTAGE_SEGMENT_EASE) ? undefined : "none"; + const edge = upsert(keyframes, oldEdge, held, linear); + if (before && linear) edge.ease ??= linear; + upsert(keyframes, before ? 0 : 100, { ...held, ...values }, before ? undefined : linear); + } + + keyframes.sort((a, b) => a.percentage - b.percentage); + return { + ok: true, + mutation: { + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: anim.targetSelector, + position: roundTo3(position), + duration: roundTo3(span), + keyframes, + ...(norm.easeEach ? { easeEach: norm.easeEach } : {}), + }, + }; +} + +/** Writes `values` into `anim` at the playhead (or the keyframe selected in the lane). */ +export async function commitValueAtPlayhead( + selection: DomEditSelection, + anim: GsapAnimation, + values: Record, + iframe: HTMLIFrameElement | null, + callbacks: GsapDragCommitCallbacks, + options: { label: string; backfill?: Record; beforeReload?: () => void }, +): Promise { + await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); + // One keyframe of a tween its siblings share would move them all. + if (tweenReach(anim, selection.element) === "shared") + return { status: "blocked", reason: "keyframes-uneditable", detail: "shared-tween" }; + const { activeKeyframePct, setActiveKeyframePct, currentTime } = usePlayerStore.getState(); + const tween = findParsedTween(iframe, selection.element, anim); + const timed = withExactStepTimes(anim, tween); + const plan = planValueAtPlayhead({ + anim: timed.duration == null && tween ? { ...timed, duration: tween.duration?.() } : timed, + at: activeKeyframePct != null ? { percentage: activeKeyframePct } : { time: currentTime }, + values, + backfill: options.backfill, + implicitEndValue: parsedImplicitEndValue(tween), + parsedEase: parsedTweenEase(iframe, tween), + }); + if (!plan.ok) return { status: "blocked", reason: "keyframes-uneditable", detail: plan.reason }; + await callbacks.commitMutation(selection, plan.mutation, { + label: options.label, + softReload: true, + beforeReload: options.beforeReload, + }); + if (activeKeyframePct != null) { + setActiveKeyframePct(null); + parkPlayheadOnKeyframe(anim, activeKeyframePct); + } + return { status: "persisted" }; +} diff --git a/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.test.ts b/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.test.ts index 9eddc4ab5a..1021f212e6 100644 --- a/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.test.ts +++ b/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.test.ts @@ -159,4 +159,57 @@ describe("commitWholePropertyOffset", () => { { type: "update-properties", animationId: "#box-hold", properties: { width: 300 } }, ]); }); + + it("keeps a linear flat tween linear: its ease becomes the keyframes' easeEach", async () => { + // A tween-level ease leaves each percentage segment power1.inOut, so the box drifts mid-segment. + const anim = { + id: "#box-rotate", + targetSelector: "#box", + method: "to", + resolvedStart: 0, + duration: 2, + ease: "none", + properties: { rotation: 40 }, + } as unknown as GsapAnimation; + const { mutations, callbacks } = recordingCallbacks(); + await commitWholePropertyOffset( + selection(), + anim, + { rotation: 50 }, + 100, + null, + callbacks, + "Rotate", + ); + expect(mutations[0]).toMatchObject({ type: "replace-with-keyframes", easeEach: "none" }); + expect(mutations[0]).not.toHaveProperty("ease", "none"); + }); + + it("keeps a keyframed tween's easeEach", async () => { + const anim = { + id: "#box-rotate", + targetSelector: "#box", + method: "to", + resolvedStart: 0, + duration: 2, + keyframes: { + easeEach: "none", + keyframes: [ + { percentage: 0, properties: { rotation: 10 } }, + { percentage: 100, properties: { rotation: 40 } }, + ], + }, + } as unknown as GsapAnimation; + const { mutations, callbacks } = recordingCallbacks(); + await commitWholePropertyOffset( + selection(), + anim, + { rotation: 50 }, + 100, + null, + callbacks, + "Rotate", + ); + expect(mutations[0]).toMatchObject({ easeEach: "none" }); + }); }); diff --git a/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.ts b/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.ts index 4dbc149510..05e280497f 100644 --- a/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.ts +++ b/packages/studio/src/hooks/gsapWholePropertyOffsetCommit.ts @@ -6,7 +6,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; import { roundTo3 } from "../utils/rounding"; -import { PROPERTY_DEFAULTS } from "./gsapShared"; +import { keyframeEases, PROPERTY_DEFAULTS } from "./gsapShared"; import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth"; import { materializeIfDynamic, type GsapDragCommitCallbacks } from "./gsapDragCommit"; @@ -38,7 +38,6 @@ export async function commitWholePropertyOffset( const ts = resolveTweenStart(effectiveAnim); const td = resolveTweenDuration(effectiveAnim); - const ease = effectiveAnim.keyframes?.easeEach ?? effectiveAnim.ease; const keys = Object.keys(newValues); const at = (props: Record, key: string) => typeof props[key] === "number" ? (props[key] as number) : (PROPERTY_DEFAULTS[key] ?? 0); @@ -79,7 +78,7 @@ export async function commitWholePropertyOffset( position: roundTo3(ts ?? 0), duration: roundTo3(td || 1), keyframes: shifted, - ease, + ...keyframeEases(effectiveAnim), }, { label, softReload: true }, ); diff --git a/packages/studio/src/hooks/newTweenTarget.test.ts b/packages/studio/src/hooks/newTweenTarget.test.ts index 33ef3e7dd7..c9237a2300 100644 --- a/packages/studio/src/hooks/newTweenTarget.test.ts +++ b/packages/studio/src/hooks/newTweenTarget.test.ts @@ -20,11 +20,11 @@ import { commitStaticGsapPosition, commitStaticGsapRotation, commitStaticGsapSize, - commitKeyframedSizeFromResize, commitWholePathOffset, findExistingPositionWrite, } from "./gsapDragCommit"; import { promoteSetToKeyframes } from "./useEnableKeyframes"; +import { commitValueAtPlayhead } from "./gsapValueAtPlayhead"; afterEach(() => { document.body.innerHTML = ""; @@ -172,30 +172,30 @@ describe("gsapDragCommit — new-tween targets", () => { expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-4"]); }); - it("commitKeyframedSizeFromResize authors the new keyframe tween against one element", async () => { + it("a keyframe edit refuses a tween its siblings share instead of moving them all", async () => { const groups = mountGroupSiblings(); const { mutations, callbacks } = recorder(); - const animatedTween = { + const sharedTween = { id: "t1", targetSelector: ".group", method: "to", - properties: {}, + properties: { width: 120 }, resolvedStart: 0, duration: 2, - keyframes: { keyframes: [{ percentage: 0, properties: { x: 0 } }] }, + ease: "none", } as unknown as GsapAnimation; - const handled = await commitKeyframedSizeFromResize( + const outcome = await commitValueAtPlayhead( classOnlySelection(groups[3]!), - { width: 80, height: 40 }, - ".group", + sharedTween, + { width: 80 }, null, - animatedTween, callbacks, + { label: "Resize" }, ); - expect(handled).toBe(true); - expect(attributedTo(writtenTargets(mutations)[0]!)).toEqual(["group-3"]); + expect(outcome).toMatchObject({ status: "blocked", detail: "shared-tween" }); + expect(mutations).toEqual([]); }); it("commitStaticGsapPosition replaces a corrupt keyframed hold against one element", async () => { diff --git a/packages/studio/src/hooks/useEnableKeyframes.test.ts b/packages/studio/src/hooks/useEnableKeyframes.test.ts index 07bf9e2353..2d442ac78c 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.test.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.test.ts @@ -378,7 +378,10 @@ function makeElementSelection(): DomEditSelection { } as DomEditSelection; } -function stubFlatTweenConversion(id: string): { +function stubFlatTweenConversion( + id: string, + easeEach?: string, +): { flat: GsapAnimation; converted: GsapAnimation; } { @@ -393,6 +396,7 @@ function stubFlatTweenConversion(id: string): { { percentage: 0, properties: { x: 0 } }, { percentage: 100, properties: { x: 10 } }, ], + ...(easeEach && { easeEach }), }, }); flatTweenResponses(flat, converted); @@ -437,6 +441,33 @@ describe("useEnableKeyframes — flat tween transaction", () => { expect(convertOptions?.coalesceKey).toEqual(expect.any(String)); }); + it("keeps the converted tween's easeEach when it extends it to the playhead", async () => { + window.location.hash = "#/project/test-project"; + usePlayerStore.setState({ currentTime: 3 }); + const { flat } = stubFlatTweenConversion("flat-1", "none"); + const commitMutation = vi.fn(async () => undefined); + const enable = renderEnableKeyframes({ + domEditSelection: makeElementSelection(), + selectedGsapAnimations: [flat], + previewIframeRef: { + current: { + contentWindow: { gsap: { getProperty: () => 10 } }, + } as unknown as HTMLIFrameElement, + }, + handleGsapAddAnimation: vi.fn(), + handleGsapConvertToKeyframes: vi.fn(async () => undefined), + handleGsapRemoveKeyframe: vi.fn(), + commitMutation, + }); + + await act(async () => enable()); + + expect(commitMutation.mock.calls[0]?.[0]).toMatchObject({ + type: "replace-with-keyframes", + easeEach: "none", + }); + }); + it("passes the convert coalesce key to an inside-range batch edit", async () => { window.location.hash = "#/project/test-project"; usePlayerStore.setState({ currentTime: 1.5 }); diff --git a/packages/studio/src/hooks/useEnableKeyframes.ts b/packages/studio/src/hooks/useEnableKeyframes.ts index c886ca58b8..f21527b213 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.ts @@ -15,6 +15,7 @@ import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCa import { existingTweenTargetSelector, computeElementPercentage, + keyframeEases, KEYFRAME_PCT_MATCH, isInstantHold, resolveEditableTweenDuration, @@ -266,7 +267,7 @@ async function extendKeyframedTweenToPlayhead( position: extended.position, duration: extended.duration, keyframes: extended.keyframes, - ease: anim.ease, + ...keyframeEases(anim), }, { label: "Add keyframe",