diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 6b2df53800..70b94d4bee 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -406,6 +406,7 @@ export function StudioApp() { panelLayout.rightInspectorPanes, panelLayout.rightCollapsed, isPlaying, + domEditSession.domEditSelection, gestureState === "recording", ); useStudioUrlState({ diff --git a/packages/studio/src/components/editor/gsapAnimationCallbacks.test.ts b/packages/studio/src/components/editor/gsapAnimationCallbacks.test.ts new file mode 100644 index 0000000000..b68a1072a5 --- /dev/null +++ b/packages/studio/src/components/editor/gsapAnimationCallbacks.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type GsapAnimationEditCallbacks, + withTrackedGsapAnimationCallbacks, +} from "./gsapAnimationCallbacks"; + +function requiredCallbacks(): GsapAnimationEditCallbacks { + return { + onUpdateProperty: vi.fn(), + onUpdateMeta: vi.fn(), + onDeleteAnimation: vi.fn(), + onAddProperty: vi.fn(), + onRemoveProperty: vi.fn(), + }; +} + +function requireCallback(callback: T | undefined): T { + if (callback === undefined) throw new Error("expected callback to be present"); + return callback; +} + +describe("withTrackedGsapAnimationCallbacks", () => { + it("keeps absent optional callbacks absent and passes preview callbacks through unchanged", () => { + const callbacks = requiredCallbacks(); + const onLivePreview = vi.fn(); + const onLivePreviewEnd = vi.fn(); + callbacks.onLivePreview = onLivePreview; + callbacks.onLivePreviewEnd = onLivePreviewEnd; + + const tracked = withTrackedGsapAnimationCallbacks(callbacks, vi.fn()); + + expect(tracked.onUpdateFromProperty).toBeUndefined(); + expect(tracked.onAddFromProperty).toBeUndefined(); + expect(tracked.onRemoveFromProperty).toBeUndefined(); + expect(tracked.onSetArcPath).toBeUndefined(); + expect(tracked.onUpdateArcSegment).toBeUndefined(); + expect(tracked.onUpdateKeyframeEase).toBeUndefined(); + expect(tracked.onSetAllKeyframeEases).toBeUndefined(); + expect(tracked.onUnroll).toBeUndefined(); + expect(tracked.onLivePreview).toBe(onLivePreview); + expect(tracked.onLivePreviewEnd).toBe(onLivePreviewEnd); + }); + + it("tracks each edit once before invoking its mutation callback", () => { + const events: string[] = []; + const mutation = (name: string) => () => events.push(`mutate:${name}`); + const callbacks: GsapAnimationEditCallbacks = { + onUpdateProperty: mutation("update-property"), + onUpdateMeta: mutation("update-meta"), + onDeleteAnimation: mutation("delete"), + onAddProperty: mutation("add-property"), + onRemoveProperty: mutation("remove-property"), + onUpdateFromProperty: mutation("update-from"), + onAddFromProperty: mutation("add-from"), + onRemoveFromProperty: mutation("remove-from"), + onSetArcPath: mutation("arc-path"), + onUpdateArcSegment: mutation("arc-segment"), + onUpdateKeyframeEase: mutation("keyframe-ease"), + onSetAllKeyframeEases: mutation("all-eases"), + onUnroll: mutation("unroll"), + }; + const tracked = withTrackedGsapAnimationCallbacks(callbacks, (control, name) => { + events.push(`track:${control}:${name}`); + }); + + tracked.onUpdateProperty("a1", "visibility", 1); + tracked.onUpdateProperty("a1", "filter", "blur(2px)"); + tracked.onUpdateProperty("a1", "opacity", 0.5); + tracked.onUpdateMeta("a1", { duration: 2, ease: "none", position: 1 }); + tracked.onDeleteAnimation("a1"); + tracked.onAddProperty("a1", "scale"); + tracked.onRemoveProperty("a1", "scale"); + requireCallback(tracked.onUpdateFromProperty)("a1", "clipPath", "none"); + requireCallback(tracked.onAddFromProperty)("a1", "x"); + requireCallback(tracked.onRemoveFromProperty)("a1", "x"); + requireCallback(tracked.onSetArcPath)("a1", { enabled: true }); + requireCallback(tracked.onSetArcPath)("a1", { enabled: true, autoRotate: true }); + requireCallback(tracked.onUpdateArcSegment)("a1", 1, {}); + requireCallback(tracked.onUpdateArcSegment)("a1", 1, { curviness: 0.5 }); + requireCallback(tracked.onUpdateKeyframeEase)("a1", 50, "power2.out"); + requireCallback(tracked.onSetAllKeyframeEases)("a1", "none"); + requireCallback(tracked.onUnroll)("a1"); + + expect(events).toEqual([ + "track:toggle:visibility", + "mutate:update-property", + "track:text:filter", + "mutate:update-property", + "track:metric:opacity", + "mutate:update-property", + "track:metric:Length", + "track:select:Speed", + "track:metric:Starts at", + "mutate:update-meta", + "track:button:Remove animation", + "mutate:delete", + "track:select:Add effect property", + "mutate:add-property", + "track:button:Remove scale", + "mutate:remove-property", + "track:text:clipPath", + "mutate:update-from", + "track:select:Add from property", + "mutate:add-from", + "track:button:Remove from x", + "mutate:remove-from", + "track:toggle:Arc motion", + "mutate:arc-path", + "track:toggle:Auto rotate", + "mutate:arc-path", + "track:button:Reset arc segment 2", + "mutate:arc-segment", + "mutate:arc-segment", + "track:select:Keyframe ease", + "mutate:keyframe-ease", + "track:select:All keyframe eases", + "mutate:all-eases", + "track:button:Unroll animation", + "mutate:unroll", + ]); + }); +}); diff --git a/packages/studio/src/components/editor/gsapAnimationCallbacks.ts b/packages/studio/src/components/editor/gsapAnimationCallbacks.ts index c07d9c83b4..e41759b04d 100644 --- a/packages/studio/src/components/editor/gsapAnimationCallbacks.ts +++ b/packages/studio/src/components/editor/gsapAnimationCallbacks.ts @@ -35,6 +35,18 @@ export interface GsapAnimationEditCallbacks { onUnroll?: (animationId: string) => void; } +type TrackDesignInput = (control: string, name: string) => void; + +function trackAnimationProperty(track: TrackDesignInput, property: string): void { + const control = + property === "visibility" + ? "toggle" + : property === "filter" || property === "clipPath" + ? "text" + : "metric"; + track(control, property); +} + // User-facing control label for each animation-meta field. The ease control is // labelled "Speed" in the card UI, so ease/easeEach map there. const ANIMATION_META_LABELS: Record = { @@ -52,7 +64,7 @@ const ANIMATION_META_LABELS: Record = * control's usage count. */ export function trackAnimationMetaUpdate( - track: (control: string, name: string) => void, + track: TrackDesignInput, updates: Record, ): void { for (const key of Object.keys(updates)) { @@ -61,3 +73,88 @@ export function trackAnimationMetaUpdate( else track("select", key); } } + +/** + * Add design-input telemetry to the shared animation-edit callback surface. + * Optional callbacks remain absent, pass-through preview callbacks keep their + * original identity, and every tracked event fires once before its mutation. + */ +export function withTrackedGsapAnimationCallbacks( + callbacks: GsapAnimationEditCallbacks, + track: TrackDesignInput, +): GsapAnimationEditCallbacks { + return { + onUpdateProperty: (animationId, property, value) => { + trackAnimationProperty(track, property); + callbacks.onUpdateProperty(animationId, property, value); + }, + onUpdateMeta: (animationId, updates) => { + trackAnimationMetaUpdate(track, updates); + callbacks.onUpdateMeta(animationId, updates); + }, + onDeleteAnimation: (animationId) => { + track("button", "Remove animation"); + callbacks.onDeleteAnimation(animationId); + }, + onAddProperty: (animationId, property) => { + track("select", "Add effect property"); + callbacks.onAddProperty(animationId, property); + }, + onRemoveProperty: (animationId, property) => { + track("button", `Remove ${property}`); + callbacks.onRemoveProperty(animationId, property); + }, + onUpdateFromProperty: callbacks.onUpdateFromProperty + ? (animationId, property, value) => { + trackAnimationProperty(track, property); + callbacks.onUpdateFromProperty?.(animationId, property, value); + } + : undefined, + onAddFromProperty: callbacks.onAddFromProperty + ? (animationId, property) => { + track("select", "Add from property"); + callbacks.onAddFromProperty?.(animationId, property); + } + : undefined, + onRemoveFromProperty: callbacks.onRemoveFromProperty + ? (animationId, property) => { + track("button", `Remove from ${property}`); + callbacks.onRemoveFromProperty?.(animationId, property); + } + : undefined, + onLivePreview: callbacks.onLivePreview, + onLivePreviewEnd: callbacks.onLivePreviewEnd, + onSetArcPath: callbacks.onSetArcPath + ? (animationId, config) => { + track("toggle", config.autoRotate !== undefined ? "Auto rotate" : "Arc motion"); + callbacks.onSetArcPath?.(animationId, config); + } + : undefined, + onUpdateArcSegment: callbacks.onUpdateArcSegment + ? (animationId, segmentIndex, update) => { + if (update.curviness === undefined) { + track("button", `Reset arc segment ${segmentIndex + 1}`); + } + callbacks.onUpdateArcSegment?.(animationId, segmentIndex, update); + } + : undefined, + onUpdateKeyframeEase: callbacks.onUpdateKeyframeEase + ? (animationId, percentage, ease) => { + track("select", "Keyframe ease"); + callbacks.onUpdateKeyframeEase?.(animationId, percentage, ease); + } + : undefined, + onSetAllKeyframeEases: callbacks.onSetAllKeyframeEases + ? (animationId, ease) => { + track("select", "All keyframe eases"); + callbacks.onSetAllKeyframeEases?.(animationId, ease); + } + : undefined, + onUnroll: callbacks.onUnroll + ? (animationId) => { + track("button", "Unroll animation"); + callbacks.onUnroll?.(animationId); + } + : undefined, + }; +} diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx b/packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx new file mode 100644 index 0000000000..09ab781c90 --- /dev/null +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx @@ -0,0 +1,306 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { TimelineElement } from "../../player"; +import type { TimelineEditCallbacks } from "../../player/components/timelineCallbacks"; +import { usePlayerStore } from "../../player/store/playerStore"; +import { installReactActEnvironment, mountReactHarness } from "../../hooks/domSelectionTestHarness"; + +installReactActEnvironment(); + +const mocks = vi.hoisted(() => ({ + actions: { + handleGsapRemoveKeyframe: vi.fn(), + handleGsapMoveKeyframeToPlayhead: vi.fn(), + handleGsapMoveKeyframe: vi.fn(), + handleGsapResizeKeyframedTween: vi.fn(), + handleGsapUpdateMeta: vi.fn(), + handleGsapAddKeyframe: vi.fn(), + handleGsapAddKeyframeBatch: vi.fn().mockResolvedValue(undefined), + handleGsapConvertToKeyframes: vi.fn(), + handleGsapRemoveAllKeyframes: vi.fn(), + handleGsapDeleteAnimation: vi.fn(), + buildDomSelectionForTimelineElement: vi.fn(), + }, + selection: { id: "box", selector: "#box", sourceFile: "index.html" }, + animations: Array(), +})); + +vi.mock("../../contexts/StudioContext", () => ({ + useStudioShellContext: () => ({ projectId: "project", activeCompPath: "index.html" }), +})); + +vi.mock("../../contexts/DomEditContext", () => ({ + useDomEditActionsContext: () => mocks.actions, + useDomEditSelectionContext: () => ({ + domEditSelection: mocks.selection, + selectedGsapAnimations: mocks.animations, + }), +})); + +import { useTimelineEditCallbacks } from "./useTimelineEditCallbacks"; + +const element: TimelineElement = { + id: "box", + key: "index.html#box", + domId: "box", + tag: "div", + start: 0, + duration: 1, + track: 0, + sourceFile: "index.html", +}; + +const flatAnimation: GsapAnimation = { + id: "box-to-0-position", + targetSelector: "#box", + method: "to", + position: 0, + resolvedStart: 0, + duration: 1, + properties: { x: 420 }, + propertyGroup: "position", +}; + +const otherFlatAnimation: GsapAnimation = { + ...flatAnimation, + id: "circle-to-0-position", + targetSelector: "#circle", +}; + +const otherKeyframedAnimation: GsapAnimation = { + ...otherFlatAnimation, + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 100, properties: { x: 420 } }, + ], + }, +}; + +function authoredInteriorAnimation(): GsapAnimation { + return { + ...flatAnimation, + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 210 } }, + { percentage: 100, properties: { x: 420 } }, + ], + }, + }; +} + +function renderCallbacks(): { callbacks: TimelineEditCallbacks; unmount: () => void } { + let callbacks: TimelineEditCallbacks | null = null; + function Harness() { + callbacks = useTimelineEditCallbacks({ + handleTimelineElementMove: vi.fn(), + handleTimelineElementsMove: vi.fn(), + handleTimelineElementResize: vi.fn(), + handleTimelineGroupResize: vi.fn(), + handleToggleTrackHidden: vi.fn(), + handleBlockedTimelineEdit: vi.fn(), + handleTimelineElementSplit: vi.fn(), + handleRazorSplit: vi.fn(), + handleRazorSplitAll: vi.fn(), + }); + return null; + } + const root = mountReactHarness(); + if (!callbacks) throw new Error("timeline callbacks did not initialize"); + return { callbacks, unmount: () => act(() => root.unmount()) }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.animations = [flatAnimation]; + mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(mocks.selection); + usePlayerStore.setState({ + currentTime: 0.5, + elements: [element], + domClipChildren: [], + keyframeCache: new Map(), + gsapAnimations: new Map([["box", [flatAnimation]]]), + }); +}); + +afterEach(() => { + usePlayerStore.setState({ + elements: [], + domClipChildren: [], + keyframeCache: new Map(), + gsapAnimations: new Map(), + }); +}); + +describe("useTimelineEditCallbacks — flat tween keyframe lanes", () => { + it("adds an interior point through the add-keyframe persist boundary", async () => { + const view = renderCallbacks(); + + await act(async () => { + await view.callbacks.onTogglePropertyGroupKeyframe?.(element, { + animationId: flatAnimation.id, + propertyGroup: "position", + tweenPercentage: 50, + properties: { x: 210 }, + remove: false, + }); + }); + + expect(mocks.actions.handleGsapAddKeyframeBatch).toHaveBeenCalledWith( + flatAnimation.id, + 50, + { x: 210 }, + undefined, + mocks.selection, + ); + expect(mocks.actions.handleGsapConvertToKeyframes).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("safely no-ops a boundary drag while the tween is still flat", () => { + const view = renderCallbacks(); + + act(() => { + view.callbacks.onMoveKeyframe?.("box", 0, 25, "position", 0, flatAnimation.id); + }); + + expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled(); + expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("deletes a non-selected element flat boundary through the clicked element's selection", async () => { + const circle: TimelineElement = { + ...element, + id: "circle", + key: "scenes/main.html#circle", + domId: "circle", + }; + usePlayerStore.setState({ + elements: [element, circle], + gsapAnimations: new Map([["scenes/main.html#circle", [otherFlatAnimation]]]), + }); + const view = renderCallbacks(); + + await act(async () => { + view.callbacks.onDeleteKeyframe?.( + "scenes/main.html#circle", + 0, + "position", + 0, + otherFlatAnimation.id, + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + // Persisted through the CLICKED element's own selection, not the current one. + expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith( + otherFlatAnimation.id, + mocks.selection, + ); + expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("removes a non-selected element authored endpoint through the clicked element's selection", async () => { + const circle: TimelineElement = { + ...element, + id: "circle", + key: "scenes/main.html#circle", + domId: "circle", + }; + usePlayerStore.setState({ + elements: [element, circle], + gsapAnimations: new Map([["index.html#circle", [otherKeyframedAnimation]]]), + }); + const view = renderCallbacks(); + + await act(async () => { + view.callbacks.onDeleteKeyframe?.( + "scenes/main.html#circle", + 100, + "position", + 100, + otherKeyframedAnimation.id, + ); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith( + otherKeyframedAnimation.id, + 100, + undefined, + mocks.selection, + ); + expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("keeps selected-element flat boundary deletion on the animation delete path", () => { + const view = renderCallbacks(); + + act(() => { + view.callbacks.onDeleteKeyframe?.("box", 0, "position", 0, flatAnimation.id); + }); + + expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith(flatAnimation.id); + expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("routes the flat lane-header remove toggle through the guarded delete path", async () => { + const view = renderCallbacks(); + + await act(async () => { + await view.callbacks.onTogglePropertyGroupKeyframe?.(element, { + animationId: flatAnimation.id, + propertyGroup: "position", + tweenPercentage: 100, + properties: { x: 420 }, + remove: true, + }); + }); + + expect(mocks.actions.handleGsapDeleteAnimation).toHaveBeenCalledWith( + flatAnimation.id, + mocks.selection, + ); + expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("keeps authored interior deletion on the per-keyframe path", () => { + mocks.animations = [authoredInteriorAnimation()]; + usePlayerStore.setState({ gsapAnimations: new Map([["box", mocks.animations]]) }); + const view = renderCallbacks(); + + act(() => { + view.callbacks.onDeleteKeyframe?.("box", 50, "position", 50, flatAnimation.id); + }); + + expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith(flatAnimation.id, 50); + expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("keeps an authored interior drag on the per-keyframe move path", () => { + mocks.animations = [authoredInteriorAnimation()]; + const view = renderCallbacks(); + + act(() => { + view.callbacks.onMoveKeyframe?.("box", 50, 75, "position", 50, flatAnimation.id); + }); + + expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith(flatAnimation.id, 50, 75); + expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled(); + view.unmount(); + }); +}); diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts index 7413aa18a4..34aadf3f23 100644 --- a/packages/studio/src/components/nle/useTimelineEditCallbacks.ts +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.ts @@ -1,4 +1,5 @@ import { useCallback, useMemo } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { TimelineElement } from "../../player"; import { usePlayerStore } from "../../player/store/playerStore"; import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing"; @@ -11,6 +12,7 @@ import { import { resolveTweenStart, resolveTweenDuration } from "../../utils/globalTimeCompiler"; import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache"; import { resolveKeyframeRetime } from "../editor/keyframeRetime"; +import type { DomEditSelection } from "../editor/domEditingTypes"; import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter"; export interface TimelineEditCallbackDeps { @@ -63,29 +65,78 @@ export function useTimelineEditCallbacks({ handleGsapResizeKeyframedTween, handleGsapUpdateMeta, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, + handleGsapDeleteAnimation, buildDomSelectionForTimelineElement, } = useDomEditActionsContext(); + const resolveElementAnimations = useCallback( + (elementKey: string): GsapAnimation[] => { + const { gsapAnimations } = usePlayerStore.getState(); + const hashIndex = elementKey.lastIndexOf("#"); + const elementId = hashIndex === -1 ? elementKey : elementKey.slice(hashIndex + 1); + const sourceFile = + hashIndex === -1 ? (activeCompPath ?? "index.html") : elementKey.slice(0, hashIndex); + return ( + gsapAnimations.get(`${sourceFile}#${elementId}`) ?? + gsapAnimations.get(`index.html#${elementId}`) ?? + gsapAnimations.get(elementId) ?? + [] + ); + }, + [activeCompPath], + ); + // Resolve a timeline-diamond callback's clip-% to the keyframe's anim id + its // tween-relative percentage (shared by the delete/move keyframe callbacks): the // diamond reports a clip-% but the script ops key on the tween-%. Prefers the // anim in the keyframe's property group, falling back to the first keyframed one. const resolveKeyframeTarget = useCallback( // fallow-ignore-next-line complexity - (pct: number): { animId: string; tweenPct: number } | null => { - const cached = usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? ""); + ( + pct: number, + propertyGroup?: string, + tweenPercentage?: number, + animationId?: string, + animations: GsapAnimation[] = selectedGsapAnimations, + ): { animId: string; tweenPct: number } | null => { + const cached = propertyGroup + ? undefined + : usePlayerStore.getState().keyframeCache.get(domEditSelection?.id ?? ""); const kf = cached?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2); - const group = kf?.propertyGroup; + const group = propertyGroup ?? kf?.propertyGroup; const anim = - (group ? selectedGsapAnimations.find((a) => a.propertyGroup === group) : undefined) ?? - selectedGsapAnimations.find((a) => a.keyframes); - return anim ? { animId: anim.id, tweenPct: kf?.tweenPercentage ?? pct } : null; + (animationId ? animations.find((a) => a.id === animationId) : undefined) ?? + (group ? animations.find((a) => a.propertyGroup === group) : undefined) ?? + animations.find((a) => a.keyframes); + return anim + ? { animId: anim.id, tweenPct: tweenPercentage ?? kf?.tweenPercentage ?? pct } + : null; }, [domEditSelection?.id, selectedGsapAnimations], ); + const removeKeyframeTarget = useCallback( + ( + animationId: string, + percentage: number, + animations: GsapAnimation[], + selectionOverride?: DomEditSelection | null, + ) => { + const animation = animations.find((candidate) => candidate.id === animationId); + if (animation && !animation.keyframes) { + if (selectionOverride === undefined) handleGsapDeleteAnimation(animationId); + else handleGsapDeleteAnimation(animationId, selectionOverride); + return; + } + if (selectionOverride === undefined) handleGsapRemoveKeyframe(animationId, percentage); + else handleGsapRemoveKeyframe(animationId, percentage, undefined, selectionOverride); + }, + [handleGsapDeleteAnimation, handleGsapRemoveKeyframe], + ); + return useMemo( () => ({ onMoveElement: handleTimelineElementMove, @@ -105,13 +156,25 @@ export function useTimelineEditCallbacks({ if (!anim) return; handleGsapRemoveAllKeyframes(anim.id); }, - onDeleteKeyframe: (_elId: string, pct: number) => { - const target = resolveKeyframeTarget(pct); - if (target) handleGsapRemoveKeyframe(target.animId, target.tweenPct); + onDeleteKeyframe: (elId, pct, group, tweenPct, animationId) => { + const animations = resolveElementAnimations(elId); + const target = resolveKeyframeTarget(pct, group, tweenPct, animationId, animations); + if (!target) return; + const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId); + if (!element) { + removeKeyframeTarget(target.animId, target.tweenPct, animations); + return; + } + // Persist through the CLICKED element's own selection so a deletion on a + // non-selected element (especially one in a different source file) commits + // against the right element instead of the current domEditSelection. + void buildDomSelectionForTimelineElement(element).then((selection) => { + removeKeyframeTarget(target.animId, target.tweenPct, animations, selection); + }); }, // Retime the keyframe to the playhead, preserving its value + ease. - onMoveKeyframeToPlayhead: (_elId: string, pct: number) => { - const target = resolveKeyframeTarget(pct); + onMoveKeyframeToPlayhead: (_elId, pct, group, tweenPct, animationId) => { + const target = resolveKeyframeTarget(pct, group, tweenPct, animationId); if (target) handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct); }, // Drag-to-retime. The diamond reports clip-%s; resolveKeyframeTarget gives @@ -122,13 +185,17 @@ export function useTimelineEditCallbacks({ // resizes the tween — position/duration grow so the dragged keyframe lands at // the drop while every other keyframe keeps its absolute time (value+ease too). // fallow-ignore-next-line complexity - onMoveKeyframe: (_elId: string, fromClipPct: number, toClipPct: number) => { - const target = resolveKeyframeTarget(fromClipPct); + onMoveKeyframe: (_elId, fromClipPct, toClipPct, group, tweenPct, animationId) => { + const target = resolveKeyframeTarget(fromClipPct, group, tweenPct, animationId); const sel = domEditSelection; if (!target || !sel) return; const anim = selectedGsapAnimations.find((a) => a.id === target.animId); const tweenStart = anim ? resolveTweenStart(anim) : null; if (!anim || tweenStart === null) return; + // Synthesized flat endpoints are clip boundaries, not authored keyframes. + // Boundary-to-clip resize wiring is intentionally deferred; ignore the + // drag rather than dispatching a free keyframe move that cannot be written. + if (!anim.keyframes) return; const tweenDuration = anim.duration ?? resolveTweenDuration(anim); const sourceFile = sel.sourceFile || activeCompPath || "index.html"; const { elements, domClipChildren } = usePlayerStore.getState(); @@ -187,6 +254,26 @@ export function useTimelineEditCallbacks({ if (flatAnim) handleGsapConvertToKeyframes(flatAnim.id); } }, + onTogglePropertyGroupKeyframe: async (element, target) => { + const selection = await buildDomSelectionForTimelineElement(element); + if (!selection) return; + if (target.remove) { + removeKeyframeTarget( + target.animationId, + target.tweenPercentage, + selectedGsapAnimations, + selection, + ); + return; + } + await handleGsapAddKeyframeBatch( + target.animationId, + target.tweenPercentage, + target.properties, + undefined, + selection, + ); + }, }), // eslint-disable-next-line react-hooks/exhaustive-deps [ @@ -200,14 +287,16 @@ export function useTimelineEditCallbacks({ handleRazorSplit, handleRazorSplitAll, handleGsapRemoveAllKeyframes, + resolveElementAnimations, resolveKeyframeTarget, + removeKeyframeTarget, selectedGsapAnimations, - handleGsapRemoveKeyframe, handleGsapMoveKeyframeToPlayhead, handleGsapMoveKeyframe, handleGsapResizeKeyframedTween, handleGsapUpdateMeta, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapConvertToKeyframes, buildDomSelectionForTimelineElement, projectId, diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx index c2b6edc6ce..be7f4e9ad6 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -44,6 +44,7 @@ export function TimelineEditProvider({ value.onMoveKeyframeToPlayhead, value.onMoveKeyframe, value.onToggleKeyframeAtPlayhead, + value.onTogglePropertyGroupKeyframe, ], ); return {children}; diff --git a/packages/studio/src/hooks/useStudioContextValue.test.ts b/packages/studio/src/hooks/useStudioContextValue.test.ts new file mode 100644 index 0000000000..01228d92ed --- /dev/null +++ b/packages/studio/src/hooks/useStudioContextValue.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment happy-dom + +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import type { DomEditSelection } from "../components/editor/domEditing"; +import type { RightInspectorPanes } from "../utils/studioHelpers"; +import { makeSelection } from "./domSelectionTestHarness"; +import { useInspectorState, type InspectorState } from "./useStudioContextValue"; + +interface HarnessProps { + rightPanelTab: string; + rightInspectorPanes: RightInspectorPanes; + rightCollapsed: boolean; + isPlaying: boolean; + isGestureRecording: boolean; + domEditSelection: DomEditSelection | null; +} + +function renderInspectorState(props: HarnessProps): InspectorState { + let state: InspectorState | null = null; + + function Harness() { + state = useInspectorState( + props.rightPanelTab, + props.rightInspectorPanes, + props.rightCollapsed, + props.isPlaying, + props.domEditSelection, + props.isGestureRecording, + ); + return null; + } + + renderToStaticMarkup(React.createElement(Harness)); + if (!state) throw new Error("Expected inspector state"); + return state; +} + +function selectedProps( + overrides: Partial = {}, +): HarnessProps & { domEditSelection: DomEditSelection } { + const element = document.createElement("div"); + return { + rightPanelTab: "renders", + rightInspectorPanes: { layers: false, design: false }, + rightCollapsed: true, + isPlaying: false, + isGestureRecording: false, + domEditSelection: makeSelection("Selected", element), + ...overrides, + }; +} + +describe("useInspectorState", () => { + it("shows the motion path for pure selection with the inspector collapsed", () => { + expect(renderInspectorState(selectedProps()).shouldShowMotionPath).toBe(true); + }); + + it("hides the motion path without a selection", () => { + expect( + renderInspectorState({ ...selectedProps(), domEditSelection: null }).shouldShowMotionPath, + ).toBe(false); + }); + + it("hides the motion path during playback", () => { + expect(renderInspectorState(selectedProps({ isPlaying: true })).shouldShowMotionPath).toBe( + false, + ); + }); + + it("hides the motion path during gesture recording", () => { + expect( + renderInspectorState(selectedProps({ isGestureRecording: true })).shouldShowMotionPath, + ).toBe(false); + }); + + it("keeps selected DOM bounds coupled to the inspector or variables panel", () => { + expect(renderInspectorState(selectedProps()).shouldShowSelectedDomBounds).toBe(false); + expect( + renderInspectorState( + selectedProps({ + rightPanelTab: "design", + rightInspectorPanes: { layers: false, design: true }, + }), + ).shouldShowSelectedDomBounds, + ).toBe(true); + expect( + renderInspectorState(selectedProps({ rightPanelTab: "variables" })) + .shouldShowSelectedDomBounds, + ).toBe(true); + }); +}); diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts index 460028bdfb..21ba371f73 100644 --- a/packages/studio/src/hooks/useStudioContextValue.ts +++ b/packages/studio/src/hooks/useStudioContextValue.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo, useRef, useState, type DragEvent } from "react"; import { STUDIO_INSPECTOR_PANELS_ENABLED } from "../components/editor/manualEditingAvailability"; +import type { DomEditSelection } from "../components/editor/domEditing"; import type { StudioContextValue } from "../contexts/StudioContext"; import type { RightInspectorPanes } from "../utils/studioHelpers"; import type { TimelineFileDropHandler } from "./useTimelineEditingTypes"; @@ -69,6 +70,7 @@ export interface InspectorState { designPanelActive: boolean; inspectorPanelActive: boolean; inspectorButtonActive: boolean; + shouldShowMotionPath: boolean; shouldShowSelectedDomBounds: boolean; } @@ -77,6 +79,7 @@ export function useInspectorState( rightInspectorPanes: RightInspectorPanes, rightCollapsed: boolean, isPlaying: boolean, + domEditSelection: DomEditSelection | null, isGestureRecording?: boolean, ): InspectorState { // fallow-ignore-next-line complexity @@ -93,8 +96,9 @@ export function useInspectorState( inspectorPanelActive, inspectorButtonActive: STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive, - // Keep the selection box + motion path drawn even when the Inspector is - // collapsed — closing the panel shouldn't visually deselect the element. + shouldShowMotionPath: !!domEditSelection && !isPlaying && !isGestureRecording, + // Keep the selection box drawn even when the Inspector is collapsed — + // closing the panel shouldn't visually deselect the element. // The Variables tab also works against the canvas selection (bind card), // so the selection outline stays visible there too. shouldShowSelectedDomBounds: @@ -102,7 +106,14 @@ export function useInspectorState( !isPlaying && !isGestureRecording, }; - }, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]); + }, [ + rightPanelTab, + rightInspectorPanes, + rightCollapsed, + isPlaying, + isGestureRecording, + domEditSelection, + ]); } // fallow-ignore-next-line complexity