diff --git a/packages/studio-server/src/routes/files.test.ts b/packages/studio-server/src/routes/files.test.ts index f2db23a3d6..ffd9f61a8c 100644 --- a/packages/studio-server/src/routes/files.test.ts +++ b/packages/studio-server/src/routes/files.test.ts @@ -1830,6 +1830,47 @@ tl.to("#box", { opacity: 1, duration: 1 }, 0); expect(result.after).not.toContain("data-hf-studio-rotation"); }); + it("replace-with-keyframes preserves per-segment easing for exact temporal keyframes", async () => { + const projectDir = createProjectDir(); + const PATH_COMP = ` +
+ +`; + writeHtml(projectDir, "path.html", PATH_COMP); + const app = new Hono(); + registerFileRoutes(app, createAdapter(projectDir)); + + const anim = await getFirstAnimation(app, "path.html"); + const res = await app.request("http://localhost/projects/demo/gsap-mutations/path.html", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: "#box", + position: 12.17, + duration: 16.055, + keyframes: [ + { percentage: 0, properties: { x: 0, y: 0 } }, + { percentage: 23.2, properties: { x: 25, y: 30 } }, + { percentage: 100, properties: { x: 100, y: 100 } }, + ], + ease: "none", + }), + }); + const result = (await res.json()) as { ok: boolean; after: string }; + + expect(res.status).toBe(200); + expect(result.ok).toBe(true); + expect(result.after).toContain('"23.2%"'); + expect(result.after).toContain('easeEach: "power1.inOut"'); + expect(result.after).toContain('ease: "none"'); + expect(result.after).not.toContain("motionPath"); + }); + it("edits a template-wrapped tween in place, preserving gsap.set and the IIFE", async () => { const projectDir = createProjectDir(); writeComp(projectDir, "scene.html", TEMPLATE_COMP); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index 01e7c3f841..3ec64bbff1 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -997,6 +997,7 @@ export type GsapMutationRequest = auto?: boolean; }>; ease?: string; + easeEach?: string; } | { type: "split-animations"; @@ -1052,6 +1053,18 @@ export type GsapMutationRequest = type GsapMutationResult = string | { script: string; skippedSelectors: string[] }; +function resolveReplacementEaseEach( + scriptText: string, + request: { animationId: string; easeEach?: string }, +): string | undefined { + if (request.easeEach !== undefined) return request.easeEach; + const original = parseGsapScriptAcorn(scriptText).animations.find( + (animation) => animation.id === request.animationId, + ); + if (!original?.arcPath?.enabled) return undefined; + return original?.keyframes?.easeEach ?? original?.ease; +} + // Mutations that can change a position tween's first keyframe (value/existence/timing) // and therefore require the pre-keyframe hold-`set`s to be re-synced afterwards. // `syncPositionHoldsBeforeKeyframes` rebuilds all `hf-hold` sets from scratch: it acts @@ -1507,6 +1520,7 @@ function executeGsapMutationAcorn( body.duration, body.keyframes, body.ease, + resolveReplacementEaseEach(block.scriptText, body), ); return added.script; } @@ -1877,6 +1891,7 @@ async function executeGsapMutationRecast( body.duration, body.keyframes, body.ease, + resolveReplacementEaseEach(block.scriptText, body), ); return added.script; } diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index ad131e3a62..7921f28e98 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -1,7 +1,7 @@ import { useState, useCallback, useRef, useMemo, useEffect, useLayoutEffect } from "react"; import type { LeftSidebarHandle, SidebarTab } from "./components/sidebar/LeftSidebar"; import { useRenderQueue } from "./components/renders/useRenderQueue"; -import { usePlayerStore, type TimelineElement } from "./player"; +import { usePlayerStore } from "./player"; import { StudioOverlays } from "./components/StudioOverlays"; import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner"; import { useCaptionStore } from "./captions/store"; @@ -12,9 +12,12 @@ import { useFileManager } from "./hooks/useFileManager"; import { usePreviewPersistence } from "./hooks/usePreviewPersistence"; import { usePreviewDocumentVersion } from "./hooks/usePreviewDocumentVersion"; import { useTimelineEditing } from "./hooks/useTimelineEditing"; -import { persistTimelineMoveEditsAtomically } from "./hooks/timelineMoveAdapter"; +import { + persistTimelineMoveEditsAtomically, + type TimelineMoveEditsHandler, + type TimelineMoveOperation, +} from "./hooks/timelineMoveAdapter"; import type { TimelineZIndexReorderCommit } from "./hooks/useTimelineEditingTypes"; -import type { TimelineStackingReorderIntent } from "./player/components/timelineStacking"; import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab"; import { useDomEditSession } from "./hooks/useDomEditSession"; import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync"; @@ -62,7 +65,6 @@ import { } from "./utils/studioUrlState"; import { trackStudioSessionStart } from "./telemetry/events"; import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config"; -type TimelineMoveOperation = Parameters[2]; // fallow-ignore-next-line complexity export function StudioApp() { const { projectId, resolving, waitingForServer } = useServerConnection(); @@ -154,6 +156,10 @@ export function StudioApp() { reloadPreview: () => setRefreshKey((k) => k + 1), pendingTimelineEditPathRef, }); + const invalidateGsapCacheRef = useRef<() => void>(() => {}); + // Stable identity — what the ref indirection is for. An inline arrow re-created + // the memoized timeline handlers (it is in their deps) on every render. + const invalidateGsapCache = useCallback(() => invalidateGsapCacheRef.current(), []); const timelineEditing = useTimelineEditing({ projectId, activeCompPath, @@ -171,20 +177,11 @@ export function StudioApp() { sdkSession: editFlowSdkSession, publishSdkSession: sdkHandle.publish, forceReloadSdkSession: sdkHandle.forceReload, + invalidateGsapCache, handleDomZIndexReorderCommitRef, }); - const handleTimelineElementsMove = useCallback( - async ( - edits: Array<{ - element: TimelineElement; - updates: Pick & { - stackingReorder?: TimelineStackingReorderIntent | null; - }; - }>, - coalesceKey?: string, - operation: TimelineMoveOperation = "timing", - coalesceMs?: number, - ) => { + const handleTimelineElementsMove: TimelineMoveEditsHandler = useCallback( + async (edits, coalesceKey, operation: TimelineMoveOperation = "timing", coalesceMs) => { const deps = { handleTimelineGroupMove: timelineEditing.handleTimelineGroupMove }; await persistTimelineMoveEditsAtomically(edits, coalesceKey, operation, deps, coalesceMs); }, @@ -228,7 +225,6 @@ export function StudioApp() { const domEditDeleteBridge = (s: DomEditSelection) => handleDomEditElementDeleteRef.current(s); const resetKeyframesRef = useRef<() => boolean>(() => false); const deleteSelectedKeyframesRef = useRef<() => void>(() => {}); - const invalidateGsapCacheRef = useRef<() => void>(() => {}); const { handleCopy, handlePaste, handleCut } = useClipboard({ projectId, activeCompPath, @@ -402,12 +398,14 @@ export function StudioApp() { designPanelActive, inspectorPanelActive, inspectorButtonActive, + shouldShowMotionPath, shouldShowSelectedDomBounds, } = useInspectorState( panelLayout.rightPanelTab, panelLayout.rightInspectorPanes, panelLayout.rightCollapsed, isPlaying, + domEditSession.domEditSelection, gestureState === "recording", ); useStudioUrlState({ @@ -558,6 +556,7 @@ export function StudioApp() { handleRazorSplitAll={timelineEditing.handleRazorSplitAll} setCompIdToSrc={setCompIdToSrc} setCompositionLoading={setCompositionLoading} + shouldShowMotionPath={shouldShowMotionPath} shouldShowSelectedDomBounds={shouldShowSelectedDomBounds} isGestureRecording={gestureState === "recording"} recordingState={gestureState} diff --git a/packages/studio/src/components/EditorShell.tsx b/packages/studio/src/components/EditorShell.tsx index a5724c12e0..fbf0688713 100644 --- a/packages/studio/src/components/EditorShell.tsx +++ b/packages/studio/src/components/EditorShell.tsx @@ -56,6 +56,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps { ) => Promise | void; setCompIdToSrc: (map: Map) => void; setCompositionLoading: (loading: boolean) => void; + shouldShowMotionPath: boolean; shouldShowSelectedDomBounds: boolean; blockPreview?: BlockPreviewInfo | null; isGestureRecording?: boolean; @@ -90,6 +91,7 @@ export function EditorShell({ handleRazorSplitAll, setCompIdToSrc, setCompositionLoading, + shouldShowMotionPath, shouldShowSelectedDomBounds, isGestureRecording, recordingState, @@ -149,6 +151,7 @@ export function EditorShell({ onDeleteElement={handleTimelineElementDelete} previewOverlay={ { usePlayerStore.setState({ autoKeyframeEnabled: true }); }); -function renderToolbar() { +function renderToolbar( + domEditSession?: React.ComponentProps["domEditSession"], +) { const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { - root.render(); + root.render(); }); return { host, root }; } @@ -54,3 +58,44 @@ describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => { act(() => root.unmount()); }); }); +describe("TimelineToolbar — motion path endpoints", () => { + it("does not advertise a destructive keyframe toggle for a required endpoint", () => { + usePlayerStore.setState({ currentTime: 10 }); + const animation: GsapAnimation = { + id: "#el-to-0-position", + targetSelector: "#el", + method: "to", + position: 0, + duration: 10, + properties: {}, + keyframes: { + format: "object-array", + keyframes: [ + { percentage: 0, properties: { x: 0, y: 0 } }, + { percentage: 100, properties: { x: 100, y: 0 } }, + ], + }, + arcPath: { + enabled: true, + autoRotate: false, + segments: [{ curviness: 1 }], + }, + }; + const element = document.createElement("div"); + element.id = "el"; + const session = { + domEditSelection: makeSelection("Element", element), + selectedGsapAnimations: [animation], + handleGsapAddAnimation: vi.fn(), + handleGsapConvertToKeyframes: vi.fn(), + handleGsapRemoveKeyframe: vi.fn(), + } satisfies NonNullable["domEditSession"]>; + + const { host, root } = renderToolbar(session); + const button = host.querySelector( + 'button[aria-label="Motion path endpoint"]', + ); + expect(button?.disabled).toBe(true); + act(() => root.unmount()); + }); +}); diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 1e314ed4ef..c745f087d1 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -5,7 +5,7 @@ import { isPlayheadWithinTween, type EnableKeyframesSession, } from "../hooks/useEnableKeyframes"; -import { computeElementPercentage } from "../hooks/gsapShared"; +import { computeElementPercentage, KEYFRAME_PCT_MATCH } from "../hooks/gsapShared"; import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard"; import { getNextTimelineZoomPercent, @@ -36,6 +36,60 @@ interface TimelineToolbarProps { onSplitElement?: (element: TimelineElement, splitTime: number) => void; } +interface KeyframeToggleState { + state: "active" | "inactive" | "none"; + isMotionPath: boolean; + pathEndpoint: boolean; + willExtend: boolean; +} + +const NO_KEYFRAME_TOGGLE: KeyframeToggleState = { + state: "none", + isMotionPath: false, + pathEndpoint: false, + willExtend: false, +}; + +function isMotionPathEndpoint(animation: GsapAnimation | undefined, percentage: number): boolean { + if (!animation?.keyframes) return false; + const keyframes = animation.keyframes.keyframes; + return ( + Math.abs((keyframes[0]?.percentage ?? -Infinity) - percentage) <= KEYFRAME_PCT_MATCH || + Math.abs((keyframes.at(-1)?.percentage ?? Infinity) - percentage) <= KEYFRAME_PCT_MATCH + ); +} + +function resolveKeyframeToggleState( + session: DomEditSessionSlice | undefined, + currentTime: number, +): KeyframeToggleState { + if (!session?.domEditSelection) return NO_KEYFRAME_TOGGLE; + const arcAnimation = session.selectedGsapAnimations.find( + (animation) => animation.arcPath && animation.keyframes, + ); + const animation = + arcAnimation ?? + session.selectedGsapAnimations.find((candidate) => candidate.keyframes && !candidate.arcPath); + if (!animation?.keyframes) return NO_KEYFRAME_TOGGLE; + + const isMotionPath = Boolean(arcAnimation); + if (!isPlayheadWithinTween(animation, currentTime, session.domEditSelection)) { + return { state: "inactive", isMotionPath, pathEndpoint: false, willExtend: true }; + } + + const percentage = computeElementPercentage(currentTime, session.domEditSelection, animation); + const pathEndpoint = isMotionPathEndpoint(arcAnimation, percentage); + const active = animation.keyframes.keyframes.some( + (keyframe) => Math.abs(keyframe.percentage - percentage) <= KEYFRAME_PCT_MATCH, + ); + return { + state: pathEndpoint ? "none" : active ? "active" : "inactive", + isMotionPath, + pathEndpoint, + willExtend: false, + }; +} + function useKeyframeToggle(session?: DomEditSessionSlice) { const currentTime = usePlayerStore((s) => s.currentTime); const sessionRef = useRef(session); @@ -45,31 +99,12 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { sessionRef as React.RefObject, ); - if (!session) return { state: "none" as const, onToggle: undefined }; - - const sel = session.domEditSelection; - const anims = session.selectedGsapAnimations; - const kfAnim = anims.find((a) => a.keyframes); - - let state: "active" | "inactive" | "none" = "none"; - // Outside the tween, clicking extends the animation to the playhead rather than - // toggling a (clamped) edge keyframe — so the button stays an "add" affordance. - let willExtend = false; - if (kfAnim?.keyframes && sel) { - if (!isPlayheadWithinTween(kfAnim, currentTime)) { - state = "inactive"; - willExtend = true; - } else { - // Tween-relative percentage (not the clip range) so the button state matches - // where the keyframe would actually land. - const pct = computeElementPercentage(currentTime, sel, kfAnim); - state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1) - ? "active" - : "inactive"; - } - } + const toggleState = resolveKeyframeToggleState(session, currentTime); - return { state, willExtend, onToggle: sel ? onToggle : undefined }; + return { + ...toggleState, + onToggle: session?.domEditSelection && !toggleState.pathEndpoint ? onToggle : undefined, + }; } // fallow-ignore-next-line complexity @@ -91,6 +126,8 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent); const { state: keyframeState, + isMotionPath: keyframeIsMotionPath, + pathEndpoint: keyframePathEndpoint, willExtend: keyframeWillExtend, onToggle: onToggleKeyframe, } = useKeyframeToggle(domEditSession); @@ -180,15 +217,23 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool // toolbar layout never shifts. + ))} + + + ) : ( + + )} + + ); +} diff --git a/packages/studio/src/components/editor/GsapAnimationSection.tsx b/packages/studio/src/components/editor/GsapAnimationSection.tsx index 33f6201b2f..2ced7b6a8e 100644 --- a/packages/studio/src/components/editor/GsapAnimationSection.tsx +++ b/packages/studio/src/components/editor/GsapAnimationSection.tsx @@ -2,13 +2,15 @@ import { memo, useState } from "react"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { Film } from "../../icons/SystemIcons"; import { Section } from "./propertyPanelPrimitives"; -import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants"; import { AnimationCard } from "./AnimationCard"; import { - trackAnimationMetaUpdate, type GsapAnimationEditCallbacks, + withTrackedGsapAnimationCallbacks, + clearFocusedEaseSegment, } from "./gsapAnimationCallbacks"; import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; +import { usePlayerStore } from "../../player"; +import { GsapAddAnimationControl } from "./GsapAddAnimationControl"; interface GsapAnimationSectionProps extends GsapAnimationEditCallbacks { animations: GsapAnimation[]; @@ -21,41 +23,13 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({ animations, multipleTimelines, unsupportedTimelinePattern, - onUpdateProperty, - onUpdateMeta, - onDeleteAnimation, - onAddProperty, - onRemoveProperty, - onUpdateFromProperty, - onAddFromProperty, - onRemoveFromProperty, onAddAnimation, - onLivePreview, - onLivePreviewEnd, - onSetArcPath, - onUpdateArcSegment, - onUpdateKeyframeEase, - onSetAllKeyframeEases, - onUnroll, + ...callbacks }: GsapAnimationSectionProps) { const track = useTrackDesignInput(); const [addMenuOpen, setAddMenuOpen] = useState(false); - const trackProperty = (property: string) => { - const control = - property === "visibility" - ? "toggle" - : property === "filter" || property === "clipPath" - ? "text" - : "metric"; - track(control, property); - }; - const updateMeta = ( - animationId: string, - updates: { duration?: number; ease?: string; position?: number }, - ) => { - trackAnimationMetaUpdate(track, updates); - onUpdateMeta(animationId, updates); - }; + const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track); + const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment); return (
}> @@ -76,137 +50,24 @@ export const GsapAnimationSection = memo(function GsapAnimationSection({
{animations.map((anim, index) => ( { - trackProperty(property); - onUpdateProperty(animationId, property, value); - }} - onUpdateMeta={updateMeta} - onDeleteAnimation={(animationId) => { - track("button", "Remove animation"); - onDeleteAnimation(animationId); - }} - onAddProperty={(animationId, property) => { - track("select", "Add effect property"); - onAddProperty(animationId, property); - }} - onRemoveProperty={(animationId, property) => { - track("button", `Remove ${property}`); - onRemoveProperty(animationId, property); - }} - onUpdateFromProperty={ - onUpdateFromProperty - ? (animationId, property, value) => { - trackProperty(property); - onUpdateFromProperty(animationId, property, value); - } - : undefined - } - onAddFromProperty={ - onAddFromProperty - ? (animationId, property) => { - track("select", "Add from property"); - onAddFromProperty(animationId, property); - } - : undefined - } - onRemoveFromProperty={ - onRemoveFromProperty - ? (animationId, property) => { - track("button", `Remove from ${property}`); - onRemoveFromProperty(animationId, property); - } - : undefined - } - onLivePreview={onLivePreview} - onLivePreviewEnd={onLivePreviewEnd} - onSetArcPath={ - onSetArcPath - ? (animationId, config) => { - track( - "toggle", - config.autoRotate !== undefined ? "Auto rotate" : "Arc motion", - ); - onSetArcPath(animationId, config); - } - : undefined - } - onUpdateArcSegment={ - onUpdateArcSegment - ? (animationId, segmentIndex, update) => { - if (update.curviness === undefined) { - track("button", `Reset arc segment ${segmentIndex + 1}`); - } - onUpdateArcSegment(animationId, segmentIndex, update); - } - : undefined - } - onUpdateKeyframeEase={ - onUpdateKeyframeEase - ? (animationId, percentage, ease) => { - track("select", "Keyframe ease"); - onUpdateKeyframeEase(animationId, percentage, ease); - } - : undefined - } - onSetAllKeyframeEases={ - onSetAllKeyframeEases - ? (animationId, ease) => { - track("select", "All keyframe eases"); - onSetAllKeyframeEases(animationId, ease); - } - : undefined - } - onUnroll={ - onUnroll - ? (animationId) => { - track("button", "Unroll animation"); - onUnroll(animationId); - } - : undefined + focusedSegment={ + focusedEaseSegment?.animationId === anim.id ? focusedEaseSegment : null } + onFocusSegmentConsumed={clearFocusedEaseSegment} /> ))} -
- {addMenuOpen ? ( -
- {ADD_METHODS.map((method) => ( - - ))} - -
- ) : ( - - )} -
+
)}
diff --git a/packages/studio/src/components/editor/KeyframeEaseList.tsx b/packages/studio/src/components/editor/KeyframeEaseList.tsx index 4574433d7b..9e53dd6118 100644 --- a/packages/studio/src/components/editor/KeyframeEaseList.tsx +++ b/packages/studio/src/components/editor/KeyframeEaseList.tsx @@ -93,7 +93,11 @@ export function KeyframeEaseList({ ? "Custom" : (EASE_LABELS[segEase] ?? segEase); return ( -
+
(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..bc5d783569 100644 --- a/packages/studio/src/components/editor/gsapAnimationCallbacks.ts +++ b/packages/studio/src/components/editor/gsapAnimationCallbacks.ts @@ -1,4 +1,5 @@ import type { ArcPathSegment } from "@hyperframes/parsers/gsap-parser"; +import { usePlayerStore } from "../../player"; /** * Edit callbacks shared by GsapAnimationSection and each AnimationCard it @@ -35,6 +36,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 = { @@ -51,13 +64,104 @@ const ANIMATION_META_LABELS: Record = * added later is attributed honestly by its own key instead of poisoning another * control's usage count. */ -export function trackAnimationMetaUpdate( - track: (control: string, name: string) => void, - updates: Record, -): void { +function trackAnimationMetaUpdate(track: TrackDesignInput, updates: Record): void { for (const key of Object.keys(updates)) { const mapped = ANIMATION_META_LABELS[key]; if (mapped) track(mapped.control, mapped.name); 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, + }; +} + +/** + * Stable consumer for the store's one-shot ease-focus request. Module-level on + * purpose: an inline arrow in the section components is a dep of AnimationCard's + * focus effect, so a fresh identity each render re-runs that effect every render. + */ +export function clearFocusedEaseSegment(): void { + usePlayerStore.getState().setFocusedEaseSegment(null); +} diff --git a/packages/studio/src/components/editor/keyframeRetime.test.ts b/packages/studio/src/components/editor/keyframeRetime.test.ts index 876c5129a5..4047d5c771 100644 --- a/packages/studio/src/components/editor/keyframeRetime.test.ts +++ b/packages/studio/src/components/editor/keyframeRetime.test.ts @@ -1,3 +1,6 @@ +// Boundary cases share an arrange/assert shape on purpose: each case states its +// own window, drag, and expected remap so a failure reads without cross-referencing. +// fallow-ignore-file code-duplication import { describe, expect, it } from "vitest"; import { resolveKeyframeRetime, type RetimeKeyframe } from "./keyframeRetime"; @@ -11,6 +14,22 @@ const KEYFRAMES: RetimeKeyframe[] = [ const WINDOW = { tweenStart: 2, tweenDuration: 4 }; const LEFT_BOUNDARY_DROP = { ...WINDOW, dropAbsTime: 0.5 }; +function expectLeftResize( + keyframes: RetimeKeyframe[], + draggedTweenPct: number, + pctRemap: Array<{ from: number; to: number }>, +): void { + const result = resolveKeyframeRetime({ + ...LEFT_BOUNDARY_DROP, + keyframes, + draggedTweenPct, + }); + expect(result.kind).toBe("resize"); + expect(result.position).toBeCloseTo(0.5, 5); + expect(result.duration).toBeCloseTo(5.5, 5); + expect(result.pctRemap).toEqual(pctRemap); +} + describe("resolveKeyframeRetime — move (within the tween window)", () => { it("re-keys an interior keyframe to the tween-% of the drop", () => { const r = resolveKeyframeRetime({ @@ -94,29 +113,21 @@ describe("resolveKeyframeRetime — resize (past the tween boundary)", () => { expect(r.kind).toBe("resize"); expect(r.position).toBeCloseTo(2, 5); // start unchanged expect(r.duration).toBeCloseTo(6, 5); // 8 - 2 - // abs 2/4/8 over the new [2,8] window → 0 / 33.3 / 100. pctRemap carries each + // abs 2/4/8 over the new [2,8] window → 0 / 33.333 / 100. pctRemap carries each // existing keyframe's old→new tween-%; the commit re-keys in place (value + // ease + _auto preserved by round-tripping the source node, not re-emitted here). expect(r.pctRemap).toEqual([ { from: 0, to: 0 }, - { from: 50, to: 33.3 }, + { from: 50, to: 33.333 }, { from: 100, to: 100 }, ]); }); it("extends the FIRST keyframe before the start, shifting position earlier", () => { - const r = resolveKeyframeRetime({ - ...LEFT_BOUNDARY_DROP, - keyframes: KEYFRAMES, - draggedTweenPct: 0, - }); - expect(r.kind).toBe("resize"); - expect(r.position).toBeCloseTo(0.5, 5); - expect(r.duration).toBeCloseTo(5.5, 5); // 6 - 0.5 - // abs 0.5/4/6 over [0.5,6] → 0 / 63.6 / 100. - expect(r.pctRemap).toEqual([ + // abs 0.5/4/6 over [0.5,6] → 0 / 63.636 / 100. + expectLeftResize(KEYFRAMES, 0, [ { from: 0, to: 0 }, - { from: 50, to: 63.6 }, + { from: 50, to: 63.636 }, { from: 100, to: 100 }, ]); }); @@ -139,15 +150,7 @@ describe("resolveKeyframeRetime — single keyframe (both first and last)", () = }); it("resizes left before the start", () => { - const r = resolveKeyframeRetime({ - ...LEFT_BOUNDARY_DROP, - keyframes: lone, - draggedTweenPct: 100, - }); - expect(r.kind).toBe("resize"); - expect(r.position).toBeCloseTo(0.5, 5); - expect(r.duration).toBeCloseTo(5.5, 5); - expect(r.pctRemap).toEqual([{ from: 100, to: 0 }]); + expectLeftResize(lone, 100, [{ from: 100, to: 0 }]); }); }); diff --git a/packages/studio/src/components/editor/keyframeRetime.ts b/packages/studio/src/components/editor/keyframeRetime.ts index f235b418ed..f7fa1d1344 100644 --- a/packages/studio/src/components/editor/keyframeRetime.ts +++ b/packages/studio/src/components/editor/keyframeRetime.ts @@ -55,7 +55,6 @@ const EPSILON_TIME = 1e-4; const MIN_TWEEN_DURATION = 0.01; const round3 = (n: number) => Math.round(n * 1000) / 1000; -const round1 = (n: number) => Math.round(n * 10) / 10; // 0.1% precision const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n)); /** Resolve timing for a flat tween's synthesized start/end diamond. */ @@ -153,7 +152,7 @@ export function resolveKeyframeRetime(opts: { const pctRemap: KeyframePctRemap[] = keyframes.map((kf, i) => { const absTime = i === draggedIdx ? dropAbsTime : tweenStart + (kf.percentage / 100) * tweenDuration; - return { from: kf.percentage, to: round1(((absTime - newStart) / newDuration) * 100) }; + return { from: kf.percentage, to: round3(((absTime - newStart) / newDuration) * 100) }; }); return { diff --git a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx index da217814f5..a9c256a003 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatLayoutSection.tsx @@ -83,10 +83,10 @@ function KeyframeGutter({ track("button", `Add ${property} keyframe`); void onCommitAnimatedProperty(element, property, displayValue); }} - onRemoveKeyframe={(pct) => { + onRemoveKeyframe={(pct, animationId) => { if (!onRemoveKeyframe) return; track("button", `Remove ${property} keyframe`); - onRemoveKeyframe(animIdForProp(property), pct); + onRemoveKeyframe(animationId ?? animIdForProp(property), pct); }} onConvertToKeyframes={() => { if (!onConvertToKeyframes) return; diff --git a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx index 7458ad508a..4823a995a2 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMotionSection.tsx @@ -6,12 +6,14 @@ import { formatTimingValue, RESPONSIVE_GRID } from "./propertyPanelHelpers"; import { parseTimingValue } from "./propertyPanelTimingSection"; import { CommitField } from "./propertyPanelPrimitives"; import { AnimationCard } from "./AnimationCard"; -import { ADD_METHODS, ADD_METHOD_LABELS, METHOD_TOOLTIPS } from "./gsapAnimationConstants"; import { - trackAnimationMetaUpdate, type GsapAnimationEditCallbacks, + withTrackedGsapAnimationCallbacks, + clearFocusedEaseSegment, } from "./gsapAnimationCallbacks"; import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation"; +import { usePlayerStore } from "../../player"; +import { GsapAddAnimationControl } from "./GsapAddAnimationControl"; export function FlatTimingRow({ element, @@ -135,15 +137,17 @@ export function FlatMotionSection({ } & GsapAnimationEditCallbacks) { const track = useTrackDesignInput(); const [addMenuOpen, setAddMenuOpen] = useState(false); - const trackProperty = (property: string) => { - const control = - property === "visibility" - ? "toggle" - : property === "filter" || property === "clipPath" - ? "text" - : "metric"; - track(control, property); - }; + const trackedCallbacks = withTrackedGsapAnimationCallbacks(callbacks, track); + const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment); + // Only consume a focus request aimed at the element THIS panel renders (not + // the store's selectedElementId, which flips synchronously during async + // selection resolution), so a shared class-selector animation id can't open + // the wrong element's editor. + const renderedElementId = `${element.sourceFile}#${element.id}`; + const focusedHere = + focusedEaseSegment && focusedEaseSegment.elementId === renderedElementId + ? focusedEaseSegment + : null; return (
@@ -172,140 +176,22 @@ export function FlatMotionSection({
{animations.map((anim, index) => ( { - trackProperty(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) => { - trackProperty(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 - } + focusedSegment={focusedHere?.animationId === anim.id ? focusedHere : null} + onFocusSegmentConsumed={clearFocusedEaseSegment} /> ))} -
- {addMenuOpen ? ( -
- {ADD_METHODS.map((method) => ( - - ))} - -
- ) : ( - - )} -
+
)} diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx index 56a7714d7c..4dbe02cc0f 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.test.tsx @@ -299,22 +299,17 @@ describe("FlatTextFieldEditor controls", () => { describe("FlatTextSection — multi-field", () => { it("shows the layer list, switches the active field's rows on selection, and has no doubled heading (this component never renders its own heading — the parent FlatGroup does)", () => { - const host = document.createElement("div"); - document.body.append(host); - const root = createRoot(host); - act(() => { - root.render( - , - ); - }); + const { host, root } = renderInto( + , + ); expect(host.textContent).toContain("Headline"); expect(host.textContent).toContain("Subhead"); // Active field's editor rows are visible (Font/Weight/etc. from FlatTextFieldEditor). @@ -408,6 +403,27 @@ describe("FlatTextSection — multi-field", () => { act(() => root.unmount()); }); + it("does not steal canvas focus when a multi-field element is selected", () => { + const focusOwner = document.createElement("button"); + document.body.append(focusOwner); + focusOwner.focus(); + + const { root } = renderInto( + , + ); + + expect(document.activeElement).toBe(focusOwner); + act(() => root.unmount()); + }); + it("auto-focuses the Content textarea when a new text field is added", async () => { let addResolved = false; diff --git a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx index ebabf758d3..954aaec9b4 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatTextSection.tsx @@ -257,6 +257,12 @@ export function FlatTextSection({ const [activeFieldKey, setActiveFieldKey] = useState( element.textFields[0]?.key ?? null, ); + // Armed by the add handler so the newly added field mounts focused. State, not + // a ref cleared during render: Strict Mode renders twice, so the first pass + // would eat the marker and the second would mount the field unfocused. Nothing + // clears it on read either — `autoFocus` is a mount-only DOM prop and the + // editor is keyed on the field, so it can only fire once per added field. + const [autoFocusFieldKey, setAutoFocusFieldKey] = useState(null); useEffect(() => { const nextFields = element.textFields; @@ -271,6 +277,8 @@ export function FlatTextSection({ const activeField = textFields.find((field) => field.key === activeFieldKey) ?? textFields[0]; if (!activeField) return null; + const autoFocusActiveField = autoFocusFieldKey === activeField.key; + if (textFields.length > 1) { return (
@@ -278,10 +286,15 @@ export function FlatTextSection({ fields={textFields} activeFieldKey={activeField.key} styles={styles} - onSelect={setActiveFieldKey} + onSelect={(fieldKey) => { + setAutoFocusFieldKey(null); + setActiveFieldKey(fieldKey); + }} onAdd={() => void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => { - if (nextKey) setActiveFieldKey(nextKey); + if (!nextKey) return; + setAutoFocusFieldKey(nextKey); + setActiveFieldKey(nextKey); }) } onRemove={onRemoveTextField} @@ -295,7 +308,7 @@ export function FlatTextSection({ onSetText={onSetText} onSetTextFieldStyle={onSetTextFieldStyle} onPreviewTextFieldStyle={onPreviewTextFieldStyle} - autoFocus + autoFocus={autoFocusActiveField} />
); @@ -316,7 +329,11 @@ export function FlatTextSection({ type="button" onClick={() => { track("button", "Add text field"); - void onAddTextField(activeField.key); + void Promise.resolve(onAddTextField(activeField.key)).then((nextKey) => { + if (!nextKey) return; + setAutoFocusFieldKey(nextKey); + setActiveFieldKey(nextKey); + }); }} className="mt-0.5 flex items-center gap-[5px] text-[10px] text-panel-text-4 hover:text-panel-text-2" > diff --git a/packages/studio/src/components/nle/PreviewOverlays.tsx b/packages/studio/src/components/nle/PreviewOverlays.tsx index 2eeb4f6294..77781d9939 100644 --- a/packages/studio/src/components/nle/PreviewOverlays.tsx +++ b/packages/studio/src/components/nle/PreviewOverlays.tsx @@ -27,6 +27,7 @@ import type { GestureRecordingState } from "../editor/GestureRecordControl"; import type { ReactNode } from "react"; export interface PreviewOverlaysProps { + shouldShowMotionPath: boolean; shouldShowSelectedDomBounds: boolean; blockPreview?: BlockPreviewInfo | null; isGestureRecording?: boolean; @@ -132,6 +133,7 @@ export function resolveZIndexEntries( // fallow-ignore-next-line complexity export function PreviewOverlays({ + shouldShowMotionPath, shouldShowSelectedDomBounds, blockPreview, isGestureRecording, @@ -274,7 +276,7 @@ export function PreviewOverlays({ {STUDIO_KEYFRAMES_ENABLED && ( diff --git a/packages/studio/src/components/nle/useTimelineEditCallbacks.test.ts b/packages/studio/src/components/nle/useTimelineEditCallbacks.test.ts index 662234daa0..361cfd615a 100644 --- a/packages/studio/src/components/nle/useTimelineEditCallbacks.test.ts +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.test.ts @@ -40,6 +40,36 @@ describe("resolveTimelineKeyframeTarget", () => { ).toBeNull(); }); + it("uses the rendered animation identity to resolve a same-group collision", () => { + expect( + resolveTimelineKeyframeTarget( + 50, + [ + { + percentage: 50, + tweenPercentage: 25, + propertyGroup: "position", + animationId: "position-b", + }, + ], + [ + { id: "position-a", propertyGroup: "position" }, + { id: "position-b", propertyGroup: "position" }, + ], + ), + ).toEqual({ animId: "position-b", tweenPct: 25 }); + }); + + it("rejects a rendered animation identity absent from the element", () => { + expect( + resolveTimelineKeyframeTarget( + 50, + [{ percentage: 50, animationId: "stale-position" }], + [{ id: "position", propertyGroup: "position" }], + ), + ).toBeNull(); + }); + it("keeps a keyframed and flat tween in the same property group unresolved", () => { expect( resolveTimelineKeyframeTarget( 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..2218cc4886 --- /dev/null +++ b/packages/studio/src/components/nle/useTimelineEditCallbacks.test.tsx @@ -0,0 +1,693 @@ +// @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().mockResolvedValue(true), + handleGsapResizeKeyframedTween: vi.fn().mockResolvedValue(true), + handleGsapUpdateMeta: vi.fn().mockResolvedValue(true), + handleGsapAddKeyframe: vi.fn(), + handleGsapAddKeyframeBatch: vi.fn().mockResolvedValue(undefined), + handleGsapConvertToKeyframes: vi.fn(), + handleGsapRemoveAllKeyframes: vi.fn().mockResolvedValue(true), + 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()) }; +} + +// One selection PER element, so a callback that resolves the selection for the +// wrong element gets a visibly different object. A single mockResolvedValue +// hands every element the same selection, which passes just as happily when the +// write is committed through whatever happens to be selected. +function selectionForElement(el: TimelineElement): { + id: string; + selector: string; + sourceFile: string; +} { + if (el.id === "box") return mocks.selection; + return { id: el.id, selector: `#${el.id}`, sourceFile: el.sourceFile ?? "index.html" }; +} + +function arrangeClickedCircle(): { + circle: TimelineElement; + selection: { id: string; selector: string; sourceFile: string }; +} { + const elementKey = "scenes/main.html#circle"; + const circle: TimelineElement = { + ...element, + id: "circle", + key: elementKey, + domId: "circle", + sourceFile: "scenes/main.html", + }; + usePlayerStore.setState({ + elements: [element, circle], + gsapAnimations: new Map([[elementKey, [otherKeyframedAnimation]]]), + }); + return { circle, selection: selectionForElement(circle) }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.animations = [flatAnimation]; + mocks.actions.buildDomSelectionForTimelineElement.mockImplementation((el: TimelineElement) => + Promise.resolve(selectionForElement(el)), + ); + 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("retimes a flat tween's boundary through update-meta, not the keyframe writer", async () => { + const view = renderCallbacks(); + + await expect( + view.callbacks.onMoveKeyframe?.( + "box", + { + percentage: 0, + propertyGroup: "position", + tweenPercentage: 0, + animationId: flatAnimation.id, + }, + 25, + ), + ).resolves.toBe(true); + + // The start boundary moved to 0.25s; the end stays put, so the window is 0.75s. + expect(mocks.actions.handleGsapUpdateMeta).toHaveBeenCalledWith( + flatAnimation.id, + { position: 0.25, duration: 0.75 }, + mocks.selection, + ); + expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled(); + // resize-keyframed-tween would convert the flat tween to keyframes form. + expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("reports an unsettled flat-boundary retime as uncommitted", async () => { + mocks.actions.handleGsapUpdateMeta.mockResolvedValueOnce(false); + const view = renderCallbacks(); + + // The diamond snaps back on `false`. Answering `true` the moment update-meta + // was dispatched left a rejected boundary drag rendered at its drop position. + await expect( + view.callbacks.onMoveKeyframe?.( + "box", + { + percentage: 0, + propertyGroup: "position", + tweenPercentage: 0, + animationId: flatAnimation.id, + }, + 25, + ), + ).resolves.toBe(false); + view.unmount(); + }); + + it("refuses a non-selected element flat boundary instead of deleting the tween", 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", { + percentage: 0, + propertyGroup: "position", + tweenPercentage: 0, + animationId: otherFlatAnimation.id, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + // Persisted through the CLICKED element's own selection, not the current one, + // and as a remove-keyframe the writer can refuse — never a whole-tween delete. + expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith( + otherFlatAnimation.id, + 0, + undefined, + selectionForElement(circle), + ); + expect(mocks.actions.handleGsapDeleteAnimation).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", { + percentage: 100, + propertyGroup: "position", + tweenPercentage: 100, + animationId: otherKeyframedAnimation.id, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith( + otherKeyframedAnimation.id, + 100, + undefined, + selectionForElement(circle), + ); + expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("deletes all keyframes through the clicked non-selected element's identity", async () => { + const { circle, selection } = arrangeClickedCircle(); + const view = renderCallbacks(); + + await act(async () => { + view.callbacks.onDeleteAllKeyframes?.(circle); + await Promise.resolve(); + }); + + expect(mocks.actions.handleGsapRemoveAllKeyframes).toHaveBeenCalledWith( + otherKeyframedAnimation.id, + selection, + ); + view.unmount(); + }); + + it("deletes all keyframes on every keyframed tween of the layer, not just the first", async () => { + const opacityAnimation: GsapAnimation = { + ...otherKeyframedAnimation, + id: "circle-to-0-visual", + propertyGroup: "visual", + }; + const { circle } = arrangeClickedCircle(); + usePlayerStore.setState({ + gsapAnimations: new Map([ + ["scenes/main.html#circle", [otherKeyframedAnimation, opacityAnimation]], + ]), + }); + const view = renderCallbacks(); + + await act(async () => { + view.callbacks.onDeleteAllKeyframes?.(circle); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mocks.actions.handleGsapRemoveAllKeyframes.mock.calls.map((call) => call[0])).toEqual([ + otherKeyframedAnimation.id, + opacityAnimation.id, + ]); + view.unmount(); + }); + + it("aborts every mutation when the clicked element resolves no selection", async () => { + const { circle } = arrangeClickedCircle(); + mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(null); + const view = renderCallbacks(); + + await act(async () => { + view.callbacks.onDeleteAllKeyframes?.(circle); + view.callbacks.onMoveKeyframeToPlayhead?.(circle, { + percentage: 100, + propertyGroup: "position", + tweenPercentage: 100, + animationId: otherKeyframedAnimation.id, + }); + view.callbacks.onDeleteKeyframe?.("scenes/main.html#circle", { + percentage: 100, + propertyGroup: "position", + tweenPercentage: 100, + animationId: otherKeyframedAnimation.id, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + // No selection for the clicked element means there is nothing safe to write + // to: falling back to the current selection would edit a different file. + expect(mocks.actions.handleGsapRemoveAllKeyframes).not.toHaveBeenCalled(); + expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).not.toHaveBeenCalled(); + expect(mocks.actions.handleGsapRemoveKeyframe).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("moves a keyframe to the playhead through the clicked non-selected element's identity", async () => { + const { circle, selection } = arrangeClickedCircle(); + const view = renderCallbacks(); + + await act(async () => { + view.callbacks.onMoveKeyframeToPlayhead?.(circle, { + percentage: 100, + propertyGroup: "position", + tweenPercentage: 100, + animationId: otherKeyframedAnimation.id, + }); + await Promise.resolve(); + }); + + // The retime target, the selection it commits through, and the animation the + // playhead percentage is computed against all come from the CLICKED element. + expect(mocks.actions.handleGsapMoveKeyframeToPlayhead).toHaveBeenCalledWith( + otherKeyframedAnimation.id, + 100, + selection, + otherKeyframedAnimation, + ); + view.unmount(); + }); + + it("keeps a selected-element flat boundary on the remove-keyframe path", () => { + const view = renderCallbacks(); + + act(() => { + view.callbacks.onDeleteKeyframe?.("box", { + percentage: 0, + propertyGroup: "position", + tweenPercentage: 0, + animationId: flatAnimation.id, + }); + }); + + expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith( + flatAnimation.id, + 0, + undefined, + undefined, + ); + expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("routes the flat lane-header remove toggle through the refusable remove 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.handleGsapRemoveKeyframe).toHaveBeenCalledWith( + flatAnimation.id, + 100, + undefined, + mocks.selection, + ); + expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled(); + view.unmount(); + }); + + // The lane-header toggle fires on whichever element owns the lane, which need + // not be the selected one. It must still commit through that element's own + // selection, and it must never escalate a flat tween to a whole-tween delete. + it("removes a non-selected element's flat tween through that element's own 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 () => { + await view.callbacks.onTogglePropertyGroupKeyframe?.(circle, { + animationId: otherFlatAnimation.id, + propertyGroup: "position", + tweenPercentage: 0, + properties: { x: 0 }, + remove: true, + }); + }); + + expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith( + otherFlatAnimation.id, + 0, + undefined, + selectionForElement(circle), + ); + expect(mocks.actions.handleGsapDeleteAnimation).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", { + percentage: 50, + propertyGroup: "position", + tweenPercentage: 50, + animationId: flatAnimation.id, + }); + }); + + expect(mocks.actions.handleGsapRemoveKeyframe).toHaveBeenCalledWith( + flatAnimation.id, + 50, + undefined, + undefined, + ); + expect(mocks.actions.handleGsapDeleteAnimation).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("keeps an authored interior drag on the per-keyframe move path", async () => { + const authored = authoredInteriorAnimation(); + mocks.animations = [authored]; + usePlayerStore.setState({ gsapAnimations: new Map([["box", [authored]]]) }); + const view = renderCallbacks(); + + await expect( + view.callbacks.onMoveKeyframe?.( + "box", + { + percentage: 50, + propertyGroup: "position", + tweenPercentage: 50, + animationId: authored.id, + }, + 75, + ), + ).resolves.toBe(true); + + expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith( + authored.id, + 50, + 75, + mocks.selection, + ); + expect(mocks.actions.handleGsapResizeKeyframedTween).not.toHaveBeenCalled(); + view.unmount(); + }); + + // A drag starts on whatever diamond the pointer is over, which need not be the + // selected element. Resolving against the selection would retime the selected + // element's tween and commit it through the selected element's file. + it("retimes a non-selected element's keyframe through that element's own selection", async () => { + const circle: TimelineElement = { + ...element, + id: "circle", + key: "scenes/main.html#circle", + domId: "circle", + sourceFile: "scenes/main.html", + }; + const circleSelection = { id: "circle", selector: "#circle", sourceFile: "scenes/main.html" }; + const circleAnimation = { ...authoredInteriorAnimation(), id: "circle-to-0-position" }; + usePlayerStore.setState({ + elements: [element, circle], + gsapAnimations: new Map([["scenes/main.html#circle", [circleAnimation]]]), + }); + mocks.actions.buildDomSelectionForTimelineElement.mockResolvedValue(circleSelection); + const view = renderCallbacks(); + + await act(async () => { + await view.callbacks.onMoveKeyframe?.( + "scenes/main.html#circle", + { + percentage: 50, + propertyGroup: "position", + tweenPercentage: 50, + animationId: circleAnimation.id, + }, + 75, + ); + }); + + expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith( + circleAnimation.id, + 50, + 75, + circleSelection, + ); + view.unmount(); + }); + + // The diamond's rapid-second-retime path reports the PENDING clip-% (where the + // first drag put the keyframe), which the keyframe cache has not caught up to. + // TimelineClipDiamonds' own test mocks onMoveKeyframe, so only this one proves + // the real callback resolves that stale-cache position off the identity fields + // instead of failing the lookup. + it("retimes from a pending position the keyframe cache has not caught up to", async () => { + const authored = authoredInteriorAnimation(); + mocks.animations = [authored]; + usePlayerStore.setState({ + elements: [element], + gsapAnimations: new Map([["index.html#box", [authored]]]), + // Still the pre-drag positions: 75% is not in here. + keyframeCache: new Map([ + [ + "index.html#box", + { + format: "percentage" as const, + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 210 } }, + { percentage: 100, properties: { x: 420 } }, + ], + }, + ], + ]), + }); + const view = renderCallbacks(); + + await expect( + view.callbacks.onMoveKeyframe?.( + "index.html#box", + { + percentage: 75, + propertyGroup: "position", + tweenPercentage: 50, + animationId: authored.id, + }, + 85, + ), + ).resolves.toBe(true); + expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith( + authored.id, + 50, + 85, + mocks.selection, + ); + + // Control: the same drag WITHOUT the identity fields falls back to the cache + // lookup, finds nothing at 75%, and cannot retime. + mocks.actions.handleGsapMoveKeyframe.mockClear(); + await expect( + view.callbacks.onMoveKeyframe?.("index.html#box", { percentage: 75 }, 85), + ).resolves.toBe(false); + expect(mocks.actions.handleGsapMoveKeyframe).not.toHaveBeenCalled(); + view.unmount(); + }); + + it("uses the clip timing basis when retiming a duration-less tween", async () => { + const durationless = { + ...authoredInteriorAnimation(), + position: 3.2, + resolvedStart: 3.2, + duration: undefined, + }; + const wideElement = { ...element, start: 10.94, duration: 16.26 }; + mocks.animations = [durationless]; + usePlayerStore.setState({ + elements: [wideElement], + gsapAnimations: new Map([["box", [durationless]]]), + }); + const view = renderCallbacks(); + + await expect( + view.callbacks.onMoveKeyframe?.( + "box", + { + percentage: 19.1, + propertyGroup: "position", + tweenPercentage: 50, + animationId: durationless.id, + }, + 40, + ), + ).resolves.toBe(true); + + // The whole point of the clip basis: the drop lands at 10.94 + 0.40 * 16.26 = + // 17.444s, and the duration-less tween borrows the clip's 16.26s window from + // its 3.2s start, so 17.444 - 3.2 over 16.26 is 87.601%. Any other basis (a + // zero-length tween, or the clip's own 0-100 %) produces a different number. + expect(mocks.actions.handleGsapMoveKeyframe).toHaveBeenCalledWith( + durationless.id, + 50, + expect.closeTo(87.601, 3), + mocks.selection, + ); + 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 76122eb47c..4b689f0251 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"; @@ -10,8 +11,15 @@ import { } from "../../contexts/DomEditContext"; import { resolveTweenStart, resolveTweenDuration } from "../../utils/globalTimeCompiler"; import { resolveClipTimingBasis } from "../../hooks/useGsapTweenCache"; +import { elementCacheKeys } from "../../hooks/gsapKeyframeCacheHelpers"; import { resolveKeyframeRetime } from "../editor/keyframeRetime"; +import type { DomEditSelection } from "../editor/domEditingTypes"; import type { TimelineMoveOperation } from "../../hooks/timelineMoveAdapter"; +import { + getTimelineElementIdentity, + splitTimelineElementKey, +} from "../../player/lib/timelineElementHelpers"; +import type { TimelineKeyframeTarget } from "../../player/components/timelineKeyframeIdentity"; export interface TimelineEditCallbackDeps { handleTimelineElementMove: ( @@ -46,15 +54,14 @@ interface TimelineCachedKeyframe { percentage: number; tweenPercentage?: number; propertyGroup?: string; + animationId?: string; } /** * Resolve a rendered timeline diamond back to the animation that authored it. - * Flat tweens use synthesized diamonds, so a mixed flat tween may have neither - * a property group nor real keyframes. The cache currently carries a property - * group, not an animation id, so resolution is safe only when that group has a - * single candidate. Ambiguous candidates remain unresolved rather than - * retiming an arbitrary tween. + * Prefer the animation identity carried by the rendered keyframe. Legacy cache + * entries without one are safe only when their property group has one candidate; + * ambiguous candidates remain unresolved rather than retiming an arbitrary tween. */ export function resolveTimelineKeyframeTarget( pct: number, @@ -63,6 +70,14 @@ export function resolveTimelineKeyframeTarget( ): { animId: string; tweenPct: number } | null { const kf = keyframes.find((item) => Math.abs(item.percentage - pct) < 0.2); if (!kf) return null; + const identifiedAnimation = kf.animationId + ? animations.find((animation) => animation.id === kf.animationId) + : undefined; + if (kf.animationId) { + return identifiedAnimation + ? { animId: identifiedAnimation.id, tweenPct: kf.tweenPercentage ?? pct } + : null; + } const group = kf?.propertyGroup; const candidates = group ? animations.filter((animation) => animation.propertyGroup === group) @@ -98,22 +113,69 @@ export function useTimelineEditCallbacks({ handleGsapResizeKeyframedTween, handleGsapUpdateMeta, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, buildDomSelectionForTimelineElement, } = useDomEditActionsContext(); + const resolveElementAnimations = useCallback( + (elementKey: string): GsapAnimation[] => { + const { gsapAnimations } = usePlayerStore.getState(); + const { sourceFile, domId } = splitTimelineElementKey(elementKey); + const scope = sourceFile ?? activeCompPath ?? "index.html"; + // elementCacheKeys owns the key-variant list the writers use; reading it + // back by hand here is how the two sides drift. + for (const key of elementCacheKeys(scope, domId)) { + const animations = gsapAnimations.get(key); + if (animations) return animations; + } + return []; + }, + [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 ?? ""); - return resolveTimelineKeyframeTarget(pct, cached?.keyframes ?? [], selectedGsapAnimations); + ( + elementKey: string, + target: TimelineKeyframeTarget, + animations: GsapAnimation[] = selectedGsapAnimations, + ): { animId: string; tweenPct: number } | null => { + const carriesIdentity = + target.propertyGroup !== undefined || + target.tweenPercentage !== undefined || + target.animationId !== undefined; + // The clicked element's own cache: the diamond context menu can open on an + // element that is not the selected one, and reading the selection's cache + // there resolves against the wrong element. + const keyframeCache = usePlayerStore.getState().keyframeCache; + const cached = + keyframeCache.get(elementKey) ?? + keyframeCache.get(splitTimelineElementKey(elementKey).domId); + return resolveTimelineKeyframeTarget( + target.percentage, + carriesIdentity ? [target] : (cached?.keyframes ?? []), + animations, + ); + }, + [selectedGsapAnimations], + ); + + const removeKeyframeTarget = useCallback( + (animationId: string, percentage: number, selectionOverride?: DomEditSelection | null) => { + // A flat tween's two diamonds are SYNTHESIZED endpoints, not authored + // keyframes, so "remove keyframe" has nothing to remove. Escalating to a + // whole-animation delete here destroyed the authored tween and its source + // comment on a single click, with no undo beyond the editor's own stack. + // Always post remove-keyframe: the writer refuses it for a flat tween + // (`changed:false`, file untouched), which is the correct no-op. + handleGsapRemoveKeyframe(animationId, percentage, undefined, selectionOverride); }, - [domEditSelection?.id, selectedGsapAnimations], + [handleGsapRemoveKeyframe], ); return useMemo( @@ -127,22 +189,59 @@ export function useTimelineEditCallbacks({ onSplitElement: handleTimelineElementSplit, onRazorSplit: handleRazorSplit, onRazorSplitAll: handleRazorSplitAll, - onDeleteAllKeyframes: () => { + onDeleteAllKeyframes: (element) => { // Hold the element where it is (collapse keyframes to a static set) rather // than deleting the whole animation — deleting strands a stale GSAP base // that the next drag adds to, flinging the element off-screen. - const anim = selectedGsapAnimations.find((a) => a.keyframes); - if (!anim) return; - handleGsapRemoveAllKeyframes(anim.id); + const elementKey = getTimelineElementIdentity(element); + // Every keyframed tween on the layer, not just the first: a layer with + // position AND opacity keyframes left the second one keyframed, so + // "Delete All Keyframes" visibly did half the job. + const anims = resolveElementAnimations(elementKey).filter( + (animation) => animation.keyframes, + ); + if (anims.length === 0) return; + void buildDomSelectionForTimelineElement(element).then(async (selection) => { + if (!selection) return; + // Serial: each removal rewrites the same source file, so dispatching + // them together would have the later writes read a pre-edit document. + for (const anim of anims) await handleGsapRemoveAllKeyframes(anim.id, selection); + }); }, - onDeleteKeyframe: (_elId: string, pct: number) => { - const target = resolveKeyframeTarget(pct); - if (target) handleGsapRemoveKeyframe(target.animId, target.tweenPct); + onDeleteKeyframe: (elId, keyframe) => { + const animations = resolveElementAnimations(elId); + const target = resolveKeyframeTarget(elId, keyframe, animations); + if (!target) return; + const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId); + if (!element) { + removeKeyframeTarget(target.animId, target.tweenPct); + 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) => { + if (selection) removeKeyframeTarget(target.animId, target.tweenPct, selection); + }); }, - // Retime the keyframe to the playhead, preserving its value + ease. - onMoveKeyframeToPlayhead: (_elId: string, pct: number) => { - const target = resolveKeyframeTarget(pct); - if (target) handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct); + // Retime the keyframe to the playhead, preserving its value + ease. The + // clicked element owns the whole write: its animations resolve the target, + // its selection commits it, and its animation computes the playhead + // percentage. Mixing frames here retimed against the selected element's + // tween and wrote the result into the clicked element's file. + onMoveKeyframeToPlayhead: (element, keyframe) => { + const elementKey = getTimelineElementIdentity(element); + const animations = resolveElementAnimations(elementKey); + const target = resolveKeyframeTarget(elementKey, keyframe, animations); + const animation = target + ? animations.find((candidate) => candidate.id === target.animId) + : undefined; + if (!target || !animation) return; + void buildDomSelectionForTimelineElement(element).then((selection) => { + if (selection) { + handleGsapMoveKeyframeToPlayhead(target.animId, target.tweenPct, selection, animation); + } + }); }, // Drag-to-retime. The diamond reports clip-%s; resolveKeyframeTarget gives // the dragged keyframe's anim + tween-%. We convert the clip-% drop to an @@ -152,14 +251,19 @@ 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); - const sel = domEditSelection; - if (!target || !sel) return; - const anim = selectedGsapAnimations.find((a) => a.id === target.animId); + onMoveKeyframe: async (elId, keyframe, toClipPct) => { + const animations = resolveElementAnimations(elId); + const target = resolveKeyframeTarget(elId, keyframe, animations); + if (!target) return false; + // The dragged diamond's OWN element, not the selected one: a drag on a + // non-selected clip has to read that clip's animations and commit + // through that clip's selection, or it retimes whatever is selected. + const element = usePlayerStore.getState().elements.find((el) => (el.key ?? el.id) === elId); + const sel = element ? await buildDomSelectionForTimelineElement(element) : domEditSelection; + if (!sel) return false; + const anim = animations.find((a) => a.id === target.animId); const tweenStart = anim ? resolveTweenStart(anim) : null; - if (!anim || tweenStart === null) return; - const tweenDuration = anim.duration ?? resolveTweenDuration(anim); + if (!anim || tweenStart === null) return Promise.resolve(false); const sourceFile = sel.sourceFile || activeCompPath || "index.html"; const { elements, domClipChildren } = usePlayerStore.getState(); const { elStart, elDuration } = resolveClipTimingBasis( @@ -168,6 +272,7 @@ export function useTimelineEditCallbacks({ elements, domClipChildren, ); + const tweenDuration = resolveTweenDuration(anim, elDuration); const dropAbsTime = elStart + (toClipPct / 100) * elDuration; const decision = resolveKeyframeRetime({ keyframes: anim.keyframes?.keyframes ?? [], @@ -177,35 +282,37 @@ export function useTimelineEditCallbacks({ dropAbsTime, }); if (decision.kind === "move" && decision.toTweenPct != null) { - handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct); + return handleGsapMoveKeyframe(target.animId, target.tweenPct, decision.toTweenPct, sel); } else if ( decision.kind === "resize" && decision.pctRemap && decision.position != null && decision.duration != null ) { - if (anim.keyframes) { - handleGsapResizeKeyframedTween( + // An empty remap means a FLAT tween's synthesized boundary: there is no + // keyframe node to re-key, only the window to move. Sending it through + // the keyframed-resize writer would rewrite the authored flat tween into + // keyframes form as a side effect of a pure position/duration change, so + // dispatch update-meta and leave the tween as the author wrote it. + if (decision.pctRemap.length === 0) { + // Report the write's real settlement, like every other branch here: + // answering `true` while the meta update is still in flight tells the + // diamond the retime landed, so a rejected write never snaps back. + return handleGsapUpdateMeta( target.animId, - decision.position, - decision.duration, - decision.pctRemap, + { position: decision.position, duration: decision.duration }, + sel, ); - } else { - // resize-keyframed-tween requires an authored `keyframes` AST node - // and intentionally no-ops for a flat tween. Update its real tween - // window through the metadata writer (and SDK cutover path) instead. - handleGsapUpdateMeta(target.animId, { - position: decision.position, - duration: decision.duration, - }); } + return handleGsapResizeKeyframedTween( + target.animId, + decision.position, + decision.duration, + decision.pctRemap, + sel, + ); } - }, - onChangeKeyframeEase: (_elId: string, _pct: number, ease: string) => { - for (const anim of selectedGsapAnimations) { - if (anim.keyframes) handleGsapUpdateMeta(anim.id, { ease }); - } + return Promise.resolve(false); }, // fallow-ignore-next-line complexity onToggleKeyframeAtPlayhead: (el: TimelineElement) => { @@ -214,18 +321,49 @@ export function useTimelineEditCallbacks({ el.duration > 0 ? Math.max(0, Math.min(100, Math.round(((currentTime - el.start) / el.duration) * 100))) : 0; - const anim = selectedGsapAnimations.find((a) => a.keyframes); - if (anim?.keyframes) { - const existing = anim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1); - if (existing) { - handleGsapRemoveKeyframe(anim.id, existing.percentage); + // Same frame for read and write: the toggled element's animations decide + // add-vs-remove, and its selection is what the mutation commits through. + const animations = resolveElementAnimations(getTimelineElementIdentity(el)); + void buildDomSelectionForTimelineElement(el).then((selection) => { + if (!selection) return; + const anim = animations.find((a) => a.keyframes); + if (anim?.keyframes) { + const existing = anim.keyframes.keyframes.find( + (k) => Math.abs(k.percentage - pct) <= 1, + ); + if (existing) { + handleGsapRemoveKeyframe(anim.id, existing.percentage, undefined, selection); + } else { + handleGsapAddKeyframe(anim.id, pct, "x", 0, selection); + } } else { - handleGsapAddKeyframe(anim.id, pct, "x", 0); + const flatAnim = animations.find((a) => !a.keyframes); + if (flatAnim) { + void handleGsapConvertToKeyframes( + flatAnim.id, + undefined, + undefined, + undefined, + selection, + ); + } } - } else { - const flatAnim = selectedGsapAnimations.find((a) => !a.keyframes); - 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, selection); + return; } + await handleGsapAddKeyframeBatch( + target.animationId, + target.tweenPercentage, + target.properties, + undefined, + selection, + ); }, }), // eslint-disable-next-line react-hooks/exhaustive-deps @@ -240,14 +378,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..ead14fa77a 100644 --- a/packages/studio/src/contexts/TimelineEditContext.tsx +++ b/packages/studio/src/contexts/TimelineEditContext.tsx @@ -40,10 +40,10 @@ export function TimelineEditProvider({ value.onRazorSplitAll, value.onDeleteKeyframe, value.onDeleteAllKeyframes, - value.onChangeKeyframeEase, value.onMoveKeyframeToPlayhead, value.onMoveKeyframe, value.onToggleKeyframeAtPlayhead, + value.onTogglePropertyGroupKeyframe, ], ); return {children}; diff --git a/packages/studio/src/hooks/deleteSelectedKeyframes.ts b/packages/studio/src/hooks/deleteSelectedKeyframes.ts index 20d5c9ff0d..8a0e4e4d2c 100644 --- a/packages/studio/src/hooks/deleteSelectedKeyframes.ts +++ b/packages/studio/src/hooks/deleteSelectedKeyframes.ts @@ -1,5 +1,5 @@ import { usePlayerStore } from "../player/store/playerStore"; -import { selectedKeyframePercentagesForElement } from "../utils/keyframeSelection"; +import { timelineKeyframeTargetFromSelectionKey } from "../player/components/timelineKeyframeIdentity"; import type { CommitMutationOptions } from "./gsapScriptCommitTypes"; let deleteKeyframesCommitCounter = 0; @@ -18,18 +18,34 @@ export function deleteSelectedKeyframes(session: { ) => void; }): void { const { selectedKeyframes, selectedElementId } = usePlayerStore.getState(); - const animation = session.selectedGsapAnimations.find((anim) => anim.keyframes); - if (!animation) return; - // Only the active element's keyframes; a stale cross-element selection must not delete here. - const percentages = selectedKeyframePercentagesForElement(selectedKeyframes, selectedElementId); + if (!selectedElementId) return; + const keyframedAnimations = session.selectedGsapAnimations.filter((anim) => anim.keyframes); + // A collapsed selection key (an ungrouped animation) carries no animation id, + // so it only resolves when there is exactly one keyframed animation it could + // mean. Taking the first of several deletes an arbitrary tween's keyframe. + const fallbackAnimation = keyframedAnimations.length === 1 ? keyframedAnimations[0] : undefined; + const animationsById = new Map(keyframedAnimations.map((animation) => [animation.id, animation])); + const removals = new Map(); + for (const key of selectedKeyframes) { + const target = timelineKeyframeTargetFromSelectionKey(selectedElementId, key); + if (!target) continue; + const animation = target.animationId + ? animationsById.get(target.animationId) + : fallbackAnimation; + if (!animation) continue; + const percentage = target.tweenPercentage ?? target.percentage; + removals.set(`${animation.id}\0${percentage}`, { animationId: animation.id, percentage }); + } + const targets = [...removals.values()]; + if (targets.length === 0) return; const coalesceOptions = { coalesceKey: `delete-keyframes:${++deleteKeyframesCommitCounter}`, coalesceMs: Number.POSITIVE_INFINITY, }; - for (const [index, pct] of percentages.entries()) { - session.handleGsapRemoveKeyframe(animation.id, pct, { + for (const [index, target] of targets.entries()) { + session.handleGsapRemoveKeyframe(target.animationId, target.percentage, { ...coalesceOptions, - ...(index === percentages.length - 1 ? { softReload: true } : { skipReload: true }), + ...(index === targets.length - 1 ? { softReload: true } : { skipReload: true }), }); } } diff --git a/packages/studio/src/hooks/gsapDragCommit.ts b/packages/studio/src/hooks/gsapDragCommit.ts index 167ce438d5..4774c17e1b 100644 --- a/packages/studio/src/hooks/gsapDragCommit.ts +++ b/packages/studio/src/hooks/gsapDragCommit.ts @@ -12,7 +12,7 @@ import { usePlayerStore } from "../player/store/playerStore"; import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes"; import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; import { roundTo3 } from "../utils/rounding"; -import { computeElementPercentage } from "./gsapShared"; +import { computeElementPercentage, idSelector } from "./gsapShared"; import { computeDraggedGsapPosition } from "./draggedGsapPosition"; import type { RuntimeTweenChange } from "./gsapRuntimePatch"; import { isGestureTransactionCommit, runGestureTransaction } from "./gestureTransaction"; @@ -117,7 +117,7 @@ export async function materializeIfDynamic( const allScanned = scanAllRuntimeKeyframes(iframe); if (allScanned.size === 0) return; const allElements = Array.from(allScanned.entries()).map(([id, data]) => ({ - selector: `#${id}`, + selector: idSelector(id), keyframes: data.keyframes, easeEach: data.easeEach, })); diff --git a/packages/studio/src/hooks/gsapDragPositionCommit.ts b/packages/studio/src/hooks/gsapDragPositionCommit.ts index ac1d760126..794e335cc2 100644 --- a/packages/studio/src/hooks/gsapDragPositionCommit.ts +++ b/packages/studio/src/hooks/gsapDragPositionCommit.ts @@ -2,6 +2,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler"; +import { KEYFRAME_PCT_MATCH, resolveEditableTweenDuration } from "./gsapShared"; import { roundTo3 } from "../utils/rounding"; import { computeDraggedGsapPosition } from "./draggedGsapPosition"; import { @@ -11,6 +12,31 @@ import { materializeIfDynamic, } from "./gsapDragCommit"; +/** + * The tween's keyframes with one inserted at `percentage`. Any existing keyframe + * within {@link KEYFRAME_PCT_MATCH} of the insert is REPLACED, not kept: the + * server takes a replace-with-keyframes list verbatim, so an append-only build + * could hand it two keyframes a fraction of a percent apart. The invariant lives + * here rather than in each caller's own pre-check, which is how the two callers + * ended up with different tolerances in the first place. + */ +export function buildTemporalArcKeyframes( + anim: GsapAnimation, + percentage: number, + properties: Record, +) { + return [ + ...(anim.keyframes?.keyframes ?? []) + .filter((keyframe) => Math.abs(keyframe.percentage - percentage) > KEYFRAME_PCT_MATCH) + .map((keyframe) => ({ + percentage: keyframe.percentage, + properties: { ...keyframe.properties }, + ...(keyframe.ease ? { ease: keyframe.ease } : {}), + })), + { percentage, properties }, + ].sort((a, b) => a.percentage - b.percentage); +} + async function extendTweenAndAddKeyframe( selection: DomEditSelection, anim: GsapAnimation, @@ -143,7 +169,7 @@ async function commitFlatViaKeyframes( ): Promise { const ct = usePlayerStore.getState().currentTime; const ts = resolveTweenStart(anim); - const td = resolveTweenDuration(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); @@ -184,8 +210,13 @@ async function commitFlatViaKeyframes( { 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.targetSelector === anim.targetSelector && a.keyframes) ?? anim; + 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( @@ -259,13 +290,61 @@ export async function commitGsapPositionFromDrag( 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); + const keyframes = anim.keyframes?.keyframes ?? []; + // Same tolerance as applyArcKeyframeAtPlayhead and isMotionPathEndpoint. A + // tighter one here meant a drag that landed a fraction of a percent off an + // authored waypoint skipped the update-point branch and appended instead. + const pointIndex = keyframes.findIndex( + (kf) => Math.abs(kf.percentage - pct) <= KEYFRAME_PCT_MATCH, + ); + if (pointIndex >= 0) { + await callbacks.commitMutation( + selection, + { + type: "update-motion-path-point", + animationId: anim.id, + pointIndex, + x: newX, + y: newY, + }, + { label: "Move layer (waypoint)", softReload: true, beforeReload: restoreOffset }, + ); + setActiveKeyframePct(null); + parkPlayheadOnKeyframe(anim, pct); + return; + } + + 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; + const temporalKeyframes = buildTemporalArcKeyframes(anim, pct, { x: newX, y: newY }); + await callbacks.commitMutation( + selection, + { + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: anim.targetSelector, + position: roundTo3(tweenStart), + duration: roundTo3(tweenDuration), + keyframes: temporalKeyframes, + ease: "none", + }, + { 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 = resolveTweenDuration(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) { @@ -293,7 +372,7 @@ export async function commitGsapPositionFromDrag( } else if (anim.method === "from" || anim.method === "fromTo") { const ct = usePlayerStore.getState().currentTime; const ts = resolveTweenStart(anim); - const td = resolveTweenDuration(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); @@ -313,7 +392,7 @@ export async function commitGsapPositionFromDrag( if (existingPosAnim?.keyframes) { const posTs = resolveTweenStart(existingPosAnim); - const posTd = resolveTweenDuration(existingPosAnim); + const posTd = resolveEditableTweenDuration(existingPosAnim, selection); if (posTs !== null) { await extendTweenAndAddKeyframe( selection, diff --git a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.test.ts b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.test.ts index 16fc62b206..05e5c93da8 100644 --- a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.test.ts +++ b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.test.ts @@ -4,6 +4,7 @@ import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerS import { clearKeyframeCacheForElement, clearKeyframeCacheForFile, + pruneKeyframeCacheToFiles, updateKeyframeCacheFromParsed, } from "./gsapKeyframeCacheHelpers"; @@ -28,7 +29,7 @@ const animWithKeyframes = (id: string): GsapAnimation => ({ }); beforeEach(() => { - usePlayerStore.setState({ keyframeCache: new Map(), elements: [] }); + usePlayerStore.setState({ keyframeCache: new Map(), gsapAnimations: new Map(), elements: [] }); }); describe("clearKeyframeCacheForElement", () => { @@ -84,6 +85,20 @@ describe("clearKeyframeCacheForFile", () => { } }); + // Several composition files re-scan concurrently, so a clear that walked the + // index.html alias would delete rows a sibling file had just written. + it("leaves an index.html-owned element alone when another file re-scans", () => { + seed("index.html#title"); + seed("title"); + seed("comp.html#a"); + + clearKeyframeCacheForFile("comp.html"); + + expect(cache().has("index.html#title")).toBe(true); + expect(cache().has("title")).toBe(true); + expect(cache().has("comp.html#a")).toBe(false); + }); + it("leaves entries that belong to a different source file", () => { seed("comp.html#a"); seed("a"); @@ -97,7 +112,84 @@ describe("clearKeyframeCacheForFile", () => { }); }); +describe("pruneKeyframeCacheToFiles", () => { + // Switching composition leaves the previous comp's elements cached with no + // owner left to clear them: each file only ever clears its own entries. + it("drops every element of a file the next scan no longer covers", () => { + seed("index.html#stress-1"); + seed("stress-1"); + seed("index.html#stress-2"); + seed("stress-2"); + seed("kf200.html#kf200"); + seed("index.html#kf200"); + seed("kf200"); + + pruneKeyframeCacheToFiles(["kf200.html"]); + + for (const key of ["index.html#stress-1", "stress-1", "index.html#stress-2", "stress-2"]) { + expect(cache().has(key)).toBe(false); + } + expect(cache().has("kf200.html#kf200")).toBe(true); + }); + + it("prunes gsapAnimations alongside keyframeCache", () => { + usePlayerStore.getState().setGsapAnimations("index.html#stress-1", [animWithKeyframes("t")]); + usePlayerStore.getState().setGsapAnimations("kf200.html#kf200", [animWithKeyframes("u")]); + + pruneKeyframeCacheToFiles(["kf200.html"]); + + const animations = usePlayerStore.getState().gsapAnimations; + expect(animations.has("index.html#stress-1")).toBe(false); + expect(animations.has("kf200.html#kf200")).toBe(true); + }); + + it("keeps everything when every cached file is still covered", () => { + seed("index.html#hero"); + seed("comp.html#a"); + + pruneKeyframeCacheToFiles(["index.html", "comp.html"]); + + expect(cache().has("index.html#hero")).toBe(true); + expect(cache().has("comp.html#a")).toBe(true); + }); +}); + describe("updateKeyframeCacheFromParsed", () => { + it("serializes a multi-keyframe tween with a stable shape and animation identity", () => { + const animation: GsapAnimation = { + ...animWithKeyframes("hero"), + duration: 2, + resolvedStart: 3, + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 100 }, ease: "power1.inOut" }, + { percentage: 100, properties: { x: 200 } }, + ], + easeEach: "power1.inOut", + }, + }; + usePlayerStore.setState({ + elements: [ + { + id: "hero-clip", + domId: "hero", + tag: "div", + start: 2, + duration: 4, + track: 0, + }, + ], + }); + + updateKeyframeCacheFromParsed([animation], "scene.html", "hero", {}); + + expect(JSON.stringify(cache().get("scene.html#hero"))).toBe( + '{"format":"percentage","keyframes":[{"percentage":25,"properties":{"x":0},"tweenPercentage":0,"propertyGroup":"position","animationId":"hero"},{"percentage":50,"properties":{"x":100},"ease":"power1.inOut","tweenPercentage":50,"propertyGroup":"position","animationId":"hero"},{"percentage":75,"properties":{"x":200},"tweenPercentage":100,"propertyGroup":"position","animationId":"hero"}],"easeEach":"power1.inOut"}', + ); + }); + it("clears the bare key when the selected element no longer has keyframes", () => { // Element previously had keyframes, so a bare entry exists (writes set both). seed("index.html#box"); @@ -118,4 +210,87 @@ describe("updateKeyframeCacheFromParsed", () => { expect(cache().has("index.html#hero")).toBe(true); expect(cache().has("hero")).toBe(true); }); + + it("caches flat tweens as clip-relative start and end keyframes", () => { + const animation: GsapAnimation = { + id: "flat-box", + targetSelector: "#box", + method: "to", + position: 1, + properties: { x: 420 }, + duration: 2, + resolvedStart: 1, + ease: "power2.out", + propertyGroup: "position", + }; + usePlayerStore.setState({ + elements: [{ id: "box-clip", domId: "box", tag: "div", start: 1, duration: 2, track: 0 }], + }); + + updateKeyframeCacheFromParsed([animation], "scene.html", "box", {}); + + expect(cache().get("scene.html#box")).toEqual({ + format: "percentage", + keyframes: [ + { + percentage: 0, + properties: { x: 0 }, + tweenPercentage: 0, + propertyGroup: "position", + animationId: "flat-box", + }, + { + percentage: 100, + properties: { x: 420 }, + ease: "power2.out", + tweenPercentage: 100, + propertyGroup: "position", + animationId: "flat-box", + }, + ], + }); + expect(usePlayerStore.getState().gsapAnimations.get("scene.html#box")).toEqual([animation]); + }); + + it("records an ungrouped tween in gsapAnimations too, so the two stores agree", () => { + // `{ x, opacity }` spans two property groups, so the parser leaves + // propertyGroup undefined. Skipping it here used to cache diamonds with no + // source animation behind them: the collapsed row drew keyframes the + // expanded lanes could not render. + const animation: GsapAnimation = { + id: "mixed-box", + targetSelector: "#box", + method: "to", + position: 0, + properties: { x: 100, opacity: 0 }, + duration: 1, + resolvedStart: 0, + }; + usePlayerStore.setState({ + elements: [{ id: "box-clip", domId: "box", tag: "div", start: 0, duration: 1, track: 0 }], + }); + + updateKeyframeCacheFromParsed([animation], "scene.html", "box", {}); + + expect(cache().has("scene.html#box")).toBe(true); + expect(usePlayerStore.getState().gsapAnimations.get("scene.html#box")).toEqual([animation]); + }); + + it("does not cache a flat tween without animatable numeric properties", () => { + const animation: GsapAnimation = { + id: "flat-box", + targetSelector: "#box", + method: "to", + position: 0, + properties: { backgroundColor: "#fff" }, + duration: 1, + propertyGroup: "visual", + }; + + updateKeyframeCacheFromParsed([animation], "scene.html", "box", {}); + + expect(cache().has("scene.html#box")).toBe(false); + expect(cache().has("box")).toBe(false); + expect(usePlayerStore.getState().gsapAnimations.has("scene.html#box")).toBe(false); + }); }); diff --git a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts index 1b5bef62b0..929960a16d 100644 --- a/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts +++ b/packages/studio/src/hooks/gsapKeyframeCacheHelpers.ts @@ -4,7 +4,8 @@ */ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore"; -import { toAbsoluteTime } from "./gsapShared"; +import { idFromSelector, toClipKeyframes } from "./gsapShared"; +import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth"; export function updateKeyframeCacheFromParsed( animations: GsapAnimation[], @@ -15,57 +16,52 @@ export function updateKeyframeCacheFromParsed( const { setKeyframeCache, elements } = usePlayerStore.getState(); const idsWithKeyframes = new Set(); const merged = new Map(); + const sourceAnimations = new Map(); for (const anim of animations) { - const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1]; - if (!id || !anim.keyframes) continue; + const id = idFromSelector(anim.targetSelector); + const kfSource = + anim.keyframes?.keyframes ?? synthesizeFlatTweenKeyframes(anim)?.keyframes ?? []; + if (!id || kfSource.length === 0) continue; idsWithKeyframes.add(id); + // Every tween that fed keyframeCache also lands in gsapAnimations, group or + // not: a mixed-group tween (`{ x, opacity }` classifies to undefined) used to + // cache diamonds with no source animation behind them, so the collapsed row + // drew keyframes the expanded lanes couldn't render. Lane consumers do the + // group filtering themselves (animationContributesLane). + sourceAnimations.set(id, [...(sourceAnimations.get(id) ?? []), anim]); // Convert tween-relative percentages to clip-relative so diamonds // render at the correct position within the timeline clip. - const tweenPos = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0); - const tweenDur = anim.duration ?? 1; const timelineEl = elements.find( (el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`, ); - const elStart = timelineEl?.start ?? 0; - const elDuration = timelineEl?.duration ?? 1; - const clipKeyframes = anim.keyframes.keyframes.map((kf) => { - const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage); - const clipPct = - elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage; - return { - ...kf, - percentage: clipPct, - tweenPercentage: kf.percentage, - propertyGroup: anim.propertyGroup, - }; - }); + const clipKeyframes = toClipKeyframes( + kfSource, + anim, + timelineEl?.start ?? 0, + timelineEl?.duration ?? 1, + ); const existing = merged.get(id); if (existing) { - const byPct = new Map(); - for (const kf of [...existing.keyframes, ...clipKeyframes]) { - const prev = byPct.get(kf.percentage); - if (prev) { - prev.properties = { ...prev.properties, ...kf.properties }; - if (kf.ease) prev.ease = kf.ease; - } else { - byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } }); - } - } - existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage); + // deduplicateKeyframes owns the same-% merge (including the easeAmbiguous + // flag downstream lanes read); a second copy of that rule here is how the + // two writers drift. + existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]); } else { - merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes }); + merged.set(id, { + ...anim.keyframes, + format: anim.keyframes?.format ?? "percentage", + keyframes: clipKeyframes, + }); } } for (const [id, entry] of merged) { - setKeyframeCache(`${targetPath}#${id}`, entry); - setKeyframeCache(id, entry); - if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry); + for (const key of elementCacheKeys(targetPath, id)) setKeyframeCache(key, entry); + writeGsapAnimationsForElement(targetPath, id, sourceAnimations.get(id)); } const targetId = - (mutation as { targetSelector?: string }).targetSelector?.match(/^#([\w-]+)/)?.[1] ?? - selectionId; + idFromSelector((mutation as { targetSelector?: string }).targetSelector) ?? selectionId; if (targetId && !idsWithKeyframes.has(targetId)) { clearKeyframeCacheForElement(targetPath, targetId); } @@ -84,40 +80,85 @@ export function updateKeyframeCacheFromParsed( * a new cache map and re-render every subscriber. */ export function clearKeyframeCacheForElement(sourceFile: string, elementId: string): void { - const { keyframeCache, setKeyframeCache } = usePlayerStore.getState(); - const keys = - sourceFile === "index.html" - ? [`index.html#${elementId}`, elementId] - : [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId]; + const { keyframeCache, setKeyframeCache, gsapAnimations, setGsapAnimations } = + usePlayerStore.getState(); + const keys = elementCacheKeys(sourceFile, elementId); for (const key of keys) { if (keyframeCache.has(key)) setKeyframeCache(key, undefined); + if (gsapAnimations.has(key)) setGsapAnimations(key, undefined); } } /** * Clear every cached element of `sourceFile` before a full re-scan repopulates - * it. Collects the element ids that currently have a prefixed or index.html - * fallback key for the file and drops each through clearKeyframeCacheForElement - * so the bare key goes too — an element whose keyframes were removed (and so is - * absent from the re-scan) leaves no stale bare entry behind. + * it. Only the file's OWN prefixed keys name the ids to clear: every write sets + * the prefixed key (see elementCacheKeys), so the file's elements are all + * reachable that way, and clearKeyframeCacheForElement then takes the + * index.html alias and the bare key with them — an element whose keyframes were + * removed (and so is absent from the re-scan) leaves no stale bare entry + * behind. Reading the alias prefix here instead would collect ids owned by + * OTHER files, and several files re-scan concurrently, so this file's clear + * would wipe the entries a sibling file had just written. */ export function clearKeyframeCacheForFile(sourceFile: string): void { - const { keyframeCache } = usePlayerStore.getState(); + const { keyframeCache, gsapAnimations } = usePlayerStore.getState(); const sfPrefix = `${sourceFile}#`; - const fallbackPrefix = "index.html#"; const ids = new Set(); - for (const key of keyframeCache.keys()) { - const matchesFile = - key.startsWith(sfPrefix) || (sourceFile !== "index.html" && key.startsWith(fallbackPrefix)); - if (!matchesFile) continue; - const hashIdx = key.indexOf("#"); - if (hashIdx !== -1) ids.add(key.slice(hashIdx + 1)); + for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) { + if (!key.startsWith(sfPrefix)) continue; + ids.add(key.slice(sfPrefix.length)); } for (const id of ids) { clearKeyframeCacheForElement(sourceFile, id); } } +/** + * Drop every cached element owned by a file that is no longer on screen. Each + * file only ever clears its OWN entries (see clearKeyframeCacheForFile), so + * switching composition left the previous composition's elements cached forever + * — 240 entries per switch on a 120-clip comp, in both keyframeCache and + * gsapAnimations, with nothing to evict them. Called once before a re-scan, with + * the full set of files that scan covers. + */ +export function pruneKeyframeCacheToFiles(files: readonly string[]): void { + const keep = new Set(files); + const { keyframeCache, gsapAnimations } = usePlayerStore.getState(); + const stale = new Map>(); + for (const key of [...keyframeCache.keys(), ...gsapAnimations.keys()]) { + const hash = key.indexOf("#"); + // Bare-id aliases carry no owner; clearKeyframeCacheForElement takes them + // with their prefixed key, so skipping them here loses nothing. + if (hash < 0) continue; + const sourceFile = key.slice(0, hash); + if (keep.has(sourceFile)) continue; + const ids = stale.get(sourceFile) ?? new Set(); + ids.add(key.slice(hash + 1)); + stale.set(sourceFile, ids); + } + for (const [sourceFile, ids] of stale) { + for (const id of ids) clearKeyframeCacheForElement(sourceFile, id); + } +} + +/** Every cache key a write for this element sets, in read-preference order. */ +export function elementCacheKeys(sourceFile: string, elementId: string): string[] { + return sourceFile === "index.html" + ? [`index.html#${elementId}`, elementId] + : [`${sourceFile}#${elementId}`, `index.html#${elementId}`, elementId]; +} + +export function writeGsapAnimationsForElement( + sourceFile: string, + elementId: string, + animations: GsapAnimation[] | undefined, +): void { + const { setGsapAnimations } = usePlayerStore.getState(); + for (const key of elementCacheKeys(sourceFile, elementId)) { + setGsapAnimations(key, animations); + } +} + function buildCacheKey(sourceFile: string, elementId: string): string { return `${sourceFile}#${elementId}`; } diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts index 7c78c05872..de5c5ef78d 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.test.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.test.ts @@ -280,3 +280,103 @@ describe("tryGsapDragIntercept — autoKeyframeEnabled toggle (#1808)", () => { expect(types).not.toContain("replace-with-keyframes"); }); }); + +describe("tryGsapDragIntercept — motion paths", () => { + const motionPathAnim = { + id: "#puck-b-to-12170-position", + targetSelector: "#puck-b", + propertyGroup: "position", + method: "to", + position: 12.17, + resolvedStart: 12.17, + duration: 16.055, + ease: "power1.inOut", + properties: {}, + keyframes: { + keyframes: [ + { percentage: 0, properties: { x: -184, y: 326 } }, + { percentage: 50, properties: { x: 416, y: 804 } }, + { percentage: 100, properties: { x: 796, y: 237 } }, + ], + }, + arcPath: { + enabled: true, + autoRotate: false, + segments: [{ curviness: 1 }, { curviness: 1 }], + }, + } as unknown as GsapAnimation; + const liveTween = { + targets: () => [{ id: "puck-b" }], + vars: { motionPath: { path: [] }, duration: 16.055 }, + duration: () => 16.055, + startTime: () => 12.17, + }; + + async function dragMotionPath(activeKeyframePct: number | null) { + usePlayerStore.setState({ + autoKeyframeEnabled: true, + activeKeyframePct, + currentTime: 15.9, + }); + const commitMutation = vi.fn(); + const handled = await tryGsapDragIntercept( + selection, + { x: -50, y: 30 }, + [motionPathAnim], + fakeIframe("puck-b", [liveTween]), + commitMutation, + ); + return { commitMutation, handled }; + } + + afterEach(() => { + usePlayerStore.setState({ activeKeyframePct: null }); + }); + + it("creates a temporal keyframe at the exact playhead instead of redistributing path waypoints", async () => { + const { commitMutation, handled } = await dragMotionPath(null); + + expect(handled).toBe(true); + expect(commitMutation).toHaveBeenCalledWith( + selection, + { + type: "replace-with-keyframes", + animationId: motionPathAnim.id, + targetSelector: "#puck-b", + position: 12.17, + duration: 16.055, + keyframes: [ + { percentage: 0, properties: { x: -184, y: 326 } }, + { percentage: 23.233, properties: { x: -50, y: 30 } }, + { percentage: 50, properties: { x: 416, y: 804 } }, + { percentage: 100, properties: { x: 796, y: 237 } }, + ], + ease: "none", + }, + expect.objectContaining({ label: "Move layer (new keyframe)", softReload: true }), + ); + expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain( + "add-motion-path-point", + ); + }); + + it("keeps an explicitly selected path waypoint as a spatial edit", async () => { + const { commitMutation, handled } = await dragMotionPath(50); + + expect(handled).toBe(true); + expect(commitMutation).toHaveBeenCalledWith( + selection, + { + type: "update-motion-path-point", + animationId: motionPathAnim.id, + pointIndex: 1, + x: -50, + y: 30, + }, + expect.objectContaining({ label: "Move layer (waypoint)", softReload: true }), + ); + expect(commitMutation.mock.calls.map(([, mutation]) => mutation.type)).not.toContain( + "replace-with-keyframes", + ); + }); +}); diff --git a/packages/studio/src/hooks/gsapScriptCommitHelpers.ts b/packages/studio/src/hooks/gsapScriptCommitHelpers.ts index d84c698608..e6d17338f3 100644 --- a/packages/studio/src/hooks/gsapScriptCommitHelpers.ts +++ b/packages/studio/src/hooks/gsapScriptCommitHelpers.ts @@ -2,12 +2,13 @@ import { findUnsafeDomPatchValues } from "@hyperframes/core/studio-api/finite-mu import type { DomEditSelection } from "../components/editor/domEditingTypes"; export { PROPERTY_DEFAULTS } from "./gsapShared"; +import { idSelector } from "./gsapShared"; export function ensureElementAddressable(selection: DomEditSelection): { selector: string; autoId?: string; } { - if (selection.id) return { selector: `#${selection.id}` }; + if (selection.id) return { selector: idSelector(selection.id) }; if (selection.selector) return { selector: selection.selector }; const el = selection.element; @@ -20,7 +21,7 @@ export function ensureElementAddressable(selection: DomEditSelection): { id = `${tag}-${n}`; } el.setAttribute("id", id); - return { selector: `#${id}`, autoId: id }; + return { selector: idSelector(id), autoId: id }; } export class GsapMutationHttpError extends Error { diff --git a/packages/studio/src/hooks/gsapScriptCommitTypes.ts b/packages/studio/src/hooks/gsapScriptCommitTypes.ts index 137dc2af18..929e79b8ec 100644 --- a/packages/studio/src/hooks/gsapScriptCommitTypes.ts +++ b/packages/studio/src/hooks/gsapScriptCommitTypes.ts @@ -16,6 +16,8 @@ export interface MutationResult { export interface CommitMutationOptions { label: string; + /** Observe the durable writer result without duplicating the request path. */ + onResult?: (result: MutationResult) => void; coalesceKey?: string; coalesceMs?: number; softReload?: boolean; diff --git a/packages/studio/src/hooks/gsapShared.test.ts b/packages/studio/src/hooks/gsapShared.test.ts index ba45743488..604b93b694 100644 --- a/packages/studio/src/hooks/gsapShared.test.ts +++ b/packages/studio/src/hooks/gsapShared.test.ts @@ -1,6 +1,31 @@ import { describe, it, expect } from "vitest"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; -import { isInstantHold, parsePercentageKeyframes } from "./gsapShared"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { + idFromSelector, + idSelector, + isInstantHold, + parsePercentageKeyframes, + resolveEditableTweenDuration, + toClipKeyframes, + toClipPercentage, +} from "./gsapShared"; + +// Fixtures carry only the fields the function under test reads; the double-cast +// is the documented way to stand in for the full runtime shape (CONTRIBUTING.md). +const tween = (duration: number | undefined) => ({ duration }) as unknown as GsapAnimation; + +describe("resolveEditableTweenDuration", () => { + const selection = { dataAttributes: { duration: "16.26" } } as unknown as DomEditSelection; + + it("uses the owning clip duration when the tween omits an outer duration", () => { + expect(resolveEditableTweenDuration(tween(undefined), selection)).toBe(16.26); + }); + + it("keeps an explicitly-authored tween duration", () => { + expect(resolveEditableTweenDuration(tween(4), selection)).toBe(4); + }); +}); describe("isInstantHold", () => { const animation = (method: GsapAnimation["method"], duration?: number) => @@ -74,3 +99,86 @@ describe("parsePercentageKeyframes", () => { expect(parsePercentageKeyframes({})).toBeNull(); }); }); + +describe("idSelector", () => { + it("uses #id for valid CSS identifiers", () => { + expect(idSelector("hero-word")).toBe("#hero-word"); + expect(idSelector("el_1")).toBe("#el_1"); + }); + + it("uses an attribute selector for ids that #id can't address (digit-leading, dots, spaces)", () => { + // #01-... / #a.b / #a b throw a SyntaxError in querySelector / GSAP, crashing + // the preview when such a target is committed (e.g. dragging the element). + expect(idSelector("01-hook-hero-word")).toBe('[id="01-hook-hero-word"]'); + expect(idSelector("my.class")).toBe('[id="my.class"]'); + expect(idSelector("1box")).toBe('[id="1box"]'); + }); + + it("escapes quotes and backslashes in the attribute selector value", () => { + expect(idSelector('1"x')).toBe('[id="1\\"x"]'); + }); + + it("only ever emits #id for ids that can't break querySelector", () => { + // Every id resolves to either a plain #id (only when safe) or an attribute + // selector — never a #id that would throw a SyntaxError. + for (const id of ["hero-word", "01-hook", "a.b", "a b", "1", "--x", '1"q']) { + const sel = idSelector(id); + if (sel.startsWith("#")) expect(sel).toBe(`#${id}`); + else expect(sel.startsWith('[id="')).toBe(true); + } + }); +}); + +describe("toClipPercentage", () => { + // Selection keys embed this number, so every keyframe-cache writer has to round + // it identically: a coarser writer rewrites the cache with a different value and + // orphans the live selection key built from the finer one. + it("keeps three decimals so a beat-snapped keyframe lands on its beat", () => { + expect(toClipPercentage(1 / 3, 0, 1, 0)).toBe(33.333); + expect(toClipPercentage(2.5, 2, 4, 0)).toBe(12.5); + }); + + it("passes the tween percentage through for a zero-length clip", () => { + expect(toClipPercentage(5, 0, 0, 42)).toBe(42); + }); +}); + +describe("toClipKeyframes", () => { + // Fixture carries only the fields the function under test reads; the + // double-cast is the documented way to stand in for the full runtime shape + // (CONTRIBUTING.md). + const durationless = { + id: "a1", + method: "to", + targetSelector: "#box", + vars: {}, + resolvedStart: 0, + } as unknown as GsapAnimation; + + // A tween with no duration spans its clip everywhere else in Studio + // (resolveEditableTweenDuration), so the cache rows have to agree: a fixed 1s + // basis put the end keyframe at 25% of a 4s clip instead of 100%. + it("spans the clip when the tween has no duration", () => { + const rows = toClipKeyframes([{ percentage: 0 }, { percentage: 100 }], durationless, 0, 4); + expect(rows.map((row) => row.percentage)).toEqual([0, 100]); + }); + + it("keeps the tween percentage and the animation identity on every row", () => { + const rows = toClipKeyframes([{ percentage: 50 }], durationless, 0, 4); + expect(rows[0]).toMatchObject({ tweenPercentage: 50, animationId: "a1" }); + }); +}); + +describe("idFromSelector", () => { + it("round-trips every shape idSelector emits", () => { + for (const id of ["hero-word", "el_1", "01-hook-hero-word", "my.class", "1box", '1"x']) { + expect(idFromSelector(idSelector(id))).toBe(id); + } + }); + + it("returns null for a selector that does not address an id", () => { + expect(idFromSelector(".dot")).toBeNull(); + expect(idFromSelector("[data-hf-id='x']")).toBeNull(); + expect(idFromSelector(undefined)).toBeNull(); + }); +}); diff --git a/packages/studio/src/hooks/gsapShared.ts b/packages/studio/src/hooks/gsapShared.ts index 459f303b46..00c79ceda6 100644 --- a/packages/studio/src/hooks/gsapShared.ts +++ b/packages/studio/src/hooks/gsapShared.ts @@ -53,14 +53,87 @@ export function isInstantHold(animation: GsapAnimation): boolean { * Returns `#id` if the selection has an id, otherwise the raw selector, * or null if neither exists. */ +/** + * A CSS-valid selector for an element id. `#id` for a valid CSS identifier, + * otherwise an `[id="..."]` attribute selector. IDs that start with a digit + * (e.g. "01-hook-hero-word") make `#id` an invalid selector, so + * `document.querySelector("#01-...")` / GSAP's `querySelectorAll` throw a + * SyntaxError — which surfaces as a masked cross-origin "Script error." and + * crashes the preview the moment such a target is committed (e.g. dragging). + */ +// Conservative: matches only ids that are unquestionably safe as a `#id` +// selector — ASCII identifier, starts with a letter/underscore (or a single +// leading hyphen), no dots/colons/spaces/digits-first. Anything it rejects +// (digit-leading like "01-hook-...", dots, spaces, non-ASCII, …) falls through +// to the attribute selector below, which is always valid. It can only ever err +// toward the safe form, never toward a `#id` that throws — and, unlike +// `CSS.escape`, it needs no browser global (this runs in node tests too). +const SAFE_HASH_ID = /^-?[A-Za-z_][\w-]*$/; + +/** + * How close (in tween-%) a playhead has to be to count as sitting ON an existing + * keyframe. Every "is there already a keyframe here?" test shares this: with two + * different tolerances in play, one path decided "no keyframe here, append one" + * while another decided "yes, edit that one", and a drag near a waypoint left two + * keyframes a fraction of a percent apart. + */ +export const KEYFRAME_PCT_MATCH = 1; + +export function idSelector(id: string): string { + // A `#id` selector is only valid for a CSS identifier. IDs that start with a + // digit (e.g. "01-hook-hero-word") make `document.querySelector("#01-...")` and + // GSAP's `querySelectorAll` throw a SyntaxError — surfacing as a masked + // cross-origin "Script error." that crashes the preview the moment such a + // target is committed (e.g. dragging the element). Address those via an + // attribute selector instead (quotes/backslashes escaped for the string). + return SAFE_HASH_ID.test(id) ? `#${id}` : `[id="${id.replace(/(["\\])/g, "\\$1")}"]`; +} + +/** + * Inverse of {@link idSelector}: the element id a target selector addresses, or + * null for a selector that is not id-based (a class, a tag, a descendant path). + * + * Both shapes have to be read back, not just `#id`. Every writer emits through + * `idSelector`, so a digit-leading, dotted or otherwise CSS-unsafe id lands in + * the source as `[id="01-hook-hero"]`. A reader that only matched `#id` saw no + * id at all for those elements and skipped them — which is how the post-commit + * keyframe-cache refresh silently stopped running for exactly the ids + * `idSelector` was added to support. + */ +export function idFromSelector(selector: string | undefined | null): string | null { + if (!selector) return null; + const hash = selector.match(/^#([\w-]+)/); + if (hash) return hash[1] ?? null; + const attribute = selector.match(/^\[id="((?:\\.|[^"\\])*)"\]/); + if (!attribute) return null; + // Undo the quote/backslash escaping idSelector applies. + return (attribute[1] ?? "").replace(/\\(["\\])/g, "$1"); +} + export function selectorFromSelection(selection: DomEditSelection): string | null { - if (selection.id) return `#${selection.id}`; + if (selection.id) return idSelector(selection.id); if (selection.selector) return selection.selector; return null; } // ── Percentage computation ──────────────────────────────────────────────────── +/** + * Resolve the timing basis used by editor keyframes. The timeline renders a + * duration-less tween across its owning clip, so mutations must use that same + * duration instead of silently falling back to GSAP's 0.5s default. + */ +export function resolveEditableTweenDuration( + animation: GsapAnimation, + selection: DomEditSelection, +): number { + const clipDuration = Number.parseFloat(selection.dataAttributes?.duration ?? ""); + return resolveTweenDuration( + animation, + Number.isFinite(clipDuration) && clipDuration > 0 ? clipDuration : 0.5, + ); +} + /** * Compute the current playback percentage within an element's animation range. * Uses the animation's resolved timing if available, otherwise falls back to @@ -73,7 +146,7 @@ export function computeElementPercentage( ): number { if (animation) { const start = resolveTweenStart(animation); - const duration = resolveTweenDuration(animation); + const duration = resolveEditableTweenDuration(animation, selection); if (duration <= 0) return 0; if (start !== null) { return absoluteToPercentage(currentTime, start, duration); @@ -81,9 +154,7 @@ export function computeElementPercentage( } const elStart = Number.parseFloat(selection.dataAttributes?.start ?? "0") || 0; const elDuration = Number.parseFloat(selection.dataAttributes?.duration ?? "1") || 1; - return elDuration > 0 - ? Math.max(0, Math.min(100, Math.round(((currentTime - elStart) / elDuration) * 1000) / 10)) - : 0; + return absoluteToPercentage(currentTime, elStart, elDuration); } // ── Iframe accessors ────────────────────────────────────────────────────────── @@ -118,6 +189,16 @@ export interface ParsedPercentageKeyframes { easeEach?: string; } +function collectAnimatableKeyframeProperties(entry: object): Record { + const properties: Record = {}; + for (const [property, value] of Object.entries(entry)) { + if (property === "ease") continue; + if (typeof value === "number") properties[property] = Math.round(value * 1000) / 1000; + else if (typeof value === "string") properties[property] = value; + } + return properties; +} + /** * Parse a GSAP percentage-keyframe object (`{ "0%": { x: 10 }, "100%": { x: 200 } }`) * into a sorted array of `{ percentage, properties }` entries. @@ -146,12 +227,7 @@ export function parsePercentageKeyframes( steps.forEach((entry, i) => { if (!entry || typeof entry !== "object") return; const percentage = steps.length > 1 ? Math.round((i / (steps.length - 1)) * 1000) / 10 : 0; - const properties: Record = {}; - for (const [pk, pv] of Object.entries(entry as Record)) { - if (pk === "ease") continue; - if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000; - else if (typeof pv === "string") properties[pk] = pv; - } + const properties = collectAnimatableKeyframeProperties(entry); if (Object.keys(properties).length > 0) keyframes.push({ percentage, properties }); }); return keyframes.length > 0 ? { keyframes } : null; @@ -165,12 +241,7 @@ export function parsePercentageKeyframes( const pctMatch = key.match(/^(\d+(?:\.\d+)?)%$/); if (!pctMatch || !val || typeof val !== "object") continue; const percentage = parseFloat(pctMatch[1]); - const properties: Record = {}; - for (const [pk, pv] of Object.entries(val as Record)) { - if (pk === "ease") continue; - if (typeof pv === "number") properties[pk] = Math.round(pv * 1000) / 1000; - else if (typeof pv === "string") properties[pk] = pv; - } + const properties = collectAnimatableKeyframeProperties(val); if (Object.keys(properties).length > 0) { keyframes.push({ percentage, properties }); } @@ -187,3 +258,57 @@ export function parsePercentageKeyframes( export function toAbsoluteTime(tweenPos: number, tweenDur: number, percentage: number): number { return tweenPos + (percentage / 100) * tweenDur; } + +/** + * An absolute time as a percentage of a timeline clip, at the one precision every + * keyframe-cache writer must share. 0.001% keeps a beat-snapped keyframe centered + * on the beat dot, and because selection keys embed this number, a writer that + * rounds coarser would orphan a live selection the moment it rewrites the cache. + * A zero-length clip has no percentage to give, so the tween-% passes through. + */ +export function toClipPercentage( + absoluteTime: number, + clipStart: number, + clipDuration: number, + fallbackPercentage: number, +): number { + if (clipDuration <= 0) return fallbackPercentage; + return Math.round(((absoluteTime - clipStart) / clipDuration) * 100000) / 1000; +} + +/** + * One keyframe-cache row per tween keyframe: the percentage re-based onto the + * clip, the original tween percentage kept alongside it, and the animation + * identity every lane and selection key needs. Shared by the cache writers so + * they cannot drift in precision or in which identity fields they record. + */ +export function toClipKeyframes( + source: readonly T[], + anim: GsapAnimation, + clipStart: number, + clipDuration: number, +): Array< + T & { + tweenPercentage: number; + propertyGroup: GsapAnimation["propertyGroup"]; + animationId: string; + } +> { + const tweenStart = anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0); + // A duration-less tween spans the clip, the same rule the edit paths use + // (resolveEditableTweenDuration). A fixed 1s here put its keyframes at a + // percentage no editor agreed with. + const tweenDuration = anim.duration ?? clipDuration; + return source.map((keyframe) => ({ + ...keyframe, + percentage: toClipPercentage( + toAbsoluteTime(tweenStart, tweenDuration, keyframe.percentage), + clipStart, + clipDuration, + keyframe.percentage, + ), + tweenPercentage: keyframe.percentage, + propertyGroup: anim.propertyGroup, + animationId: anim.id, + })); +} diff --git a/packages/studio/src/hooks/gsapTweenSynth.test.ts b/packages/studio/src/hooks/gsapTweenSynth.test.ts index 3d8d771222..b5474cf1de 100644 --- a/packages/studio/src/hooks/gsapTweenSynth.test.ts +++ b/packages/studio/src/hooks/gsapTweenSynth.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; -import { synthesizeFlatTweenKeyframes } from "./gsapTweenSynth"; +import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth"; function anim(overrides: Partial): GsapAnimation { return { @@ -53,3 +53,32 @@ describe("synthesizeFlatTweenKeyframes", () => { expect(out).not.toBeNull(); }); }); + +describe("deduplicateKeyframes ease ambiguity", () => { + it("flags a same-% collision from different animations (different eases)", () => { + const merged = deduplicateKeyframes([ + { percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" }, + { percentage: 45, properties: { opacity: 1 }, ease: "power2.out", animationId: "#a-visual" }, + ]); + const kf = merged.find((k) => k.percentage === 45); + expect(kf?.easeAmbiguous).toBe(true); + }); + + it("flags a cross-animation collision even when the raw eases match", () => { + // The button can still only target one arbitrary animation, and each may + // inherit a different easeEach/animation ease that raw comparison misses. + const merged = deduplicateKeyframes([ + { percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" }, + { percentage: 45, properties: { opacity: 1 }, ease: "power2.in", animationId: "#a-visual" }, + ]); + expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBe(true); + }); + + it("does not flag a same-% collision within a single animation", () => { + const merged = deduplicateKeyframes([ + { percentage: 45, properties: { x: 10 }, ease: "power2.in", animationId: "#a-position" }, + { percentage: 45, properties: { y: 20 }, ease: "power2.out", animationId: "#a-position" }, + ]); + expect(merged.find((k) => k.percentage === 45)?.easeAmbiguous).toBeFalsy(); + }); +}); diff --git a/packages/studio/src/hooks/gsapTweenSynth.ts b/packages/studio/src/hooks/gsapTweenSynth.ts index edb849f28c..a3b100be4d 100644 --- a/packages/studio/src/hooks/gsapTweenSynth.ts +++ b/packages/studio/src/hooks/gsapTweenSynth.ts @@ -5,15 +5,51 @@ import type { } from "@hyperframes/core/gsap-parser"; import { PROPERTY_DEFAULTS } from "./gsapShared"; -export function deduplicateKeyframes( - keyframes: GsapPercentageKeyframe[], -): GsapPercentageKeyframe[] { - const byPct = new Map(); +/** + * A static position hold (only x/y, no real motion) is a `set`, not a keyframe — + * it must not synthesize a diamond. Covers both `tl.set(...)` and the + * `tl.to({ duration: 0, immediateRender: true })` hold that remove-all-keyframes + * collapses to (otherwise shown as a stray 0% keyframe). + * + * Single owner: the collapsed keyframe cache and the expanded property lanes' + * `gsapAnimations` map MUST agree on it, or a hold draws a phantom expanded lane + * with no matching collapsed diamond. + */ +export function isStaticPositionHold(anim: GsapAnimation): boolean { + if (anim.keyframes) return false; + if (anim.method !== "set" && (anim.duration ?? 0) !== 0) return false; + const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender"); + return propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y"); +} + +export function deduplicateKeyframes< + T extends GsapPercentageKeyframe & { animationId?: string; easeAmbiguous?: boolean }, +>(keyframes: T[]): T[] { + const byPct = new Map(); for (const kf of keyframes) { const existing = byPct.get(kf.percentage); if (existing) { existing.properties = { ...existing.properties, ...kf.properties }; - if (kf.ease) existing.ease = kf.ease; + // Two DIFFERENT source animations with a keyframe at the same clip %: a + // single inline ease button can only target one of them, and which one is + // arbitrary (each may also inherit a different easeEach/animation ease, so + // comparing raw keyframe eases isn't enough). Flag it so the collapsed row + // hides the button there and the user edits per-lane instead. + if ( + existing.animationId !== undefined && + kf.animationId !== undefined && + existing.animationId !== kf.animationId + ) { + existing.easeAmbiguous = true; + } + // Whichever tween iterated last used to win `ease`, so the merged + // keyframe carried an arbitrary one of the colliding curves. Readers that + // do not check easeAmbiguous (drag readouts, lane hints) then showed a + // curve belonging to a different animation than the one an edit targets. + // Drop it instead: ambiguous means "no single ease", and the flag is the + // only honest answer. + if (existing.easeAmbiguous) delete existing.ease; + else if (kf.ease) existing.ease = kf.ease; } else { byPct.set(kf.percentage, { ...kf, properties: { ...kf.properties } }); } @@ -41,29 +77,40 @@ export function synthesizeFlatTweenKeyframes(anim: GsapAnimation): GsapKeyframes const fromProps = anim.fromProperties; if (!toProps || Object.keys(toProps).length === 0) return null; - const startProps: Record = {}; - const endProps: Record = {}; + const rawStart: Record = {}; + const rawEnd: Record = {}; if (anim.method === "from") { for (const [k, v] of Object.entries(toProps)) { - startProps[k] = v; - endProps[k] = PROPERTY_DEFAULTS[k] ?? 0; + rawStart[k] = v; + rawEnd[k] = PROPERTY_DEFAULTS[k] ?? 0; } } else if (anim.method === "fromTo" && fromProps) { - Object.assign(startProps, fromProps); - Object.assign(endProps, toProps); + Object.assign(rawStart, fromProps); + Object.assign(rawEnd, toProps); } else { for (const [k, v] of Object.entries(toProps)) { - startProps[k] = PROPERTY_DEFAULTS[k] ?? 0; - endProps[k] = v; + rawStart[k] = PROPERTY_DEFAULTS[k] ?? 0; + rawEnd[k] = v; } } + // Only numeric props are keyframe-interpolatable — a flat tween of a + // non-numeric prop (e.g. backgroundColor: "#fff") can't be a 2-keyframe lane. + const numericKeys = Object.keys(rawEnd).filter( + (k) => typeof rawStart[k] === "number" && typeof rawEnd[k] === "number", + ); + if (numericKeys.length === 0) return null; + const startProps = Object.fromEntries(numericKeys.map((k) => [k, rawStart[k]])); + const endProps = Object.fromEntries(numericKeys.map((k) => [k, rawEnd[k]])); + return { format: "percentage", keyframes: [ { percentage: 0, properties: startProps }, - { percentage: 100, properties: endProps }, + // Segment ease lives on the destination keyframe (Figma/AE model) so the + // lane + cache surface it; also kept data-level for useGsapTweenCache. + { percentage: 100, properties: endProps, ...(anim.ease ? { ease: anim.ease } : {}) }, ], ...(anim.ease ? { ease: anim.ease } : {}), }; diff --git a/packages/studio/src/hooks/keyframeCacheAstLoad.ts b/packages/studio/src/hooks/keyframeCacheAstLoad.ts new file mode 100644 index 0000000000..bca5f4b233 --- /dev/null +++ b/packages/studio/src/hooks/keyframeCacheAstLoad.ts @@ -0,0 +1,181 @@ +/** + * Reading a composition file's GSAP tweens into the keyframe cache: fetch, + * selector -> element id resolution, and the clip-relative timing basis. + * Split from useGsapTweenCache to keep that file under the 600-line limit. + */ +import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser"; +import { isStudioHoldSet } from "@hyperframes/core/gsap-parser"; +import { usePlayerStore } from "../player/store/playerStore"; +import { + clearKeyframeCacheForFile, + elementCacheKeys, + writeGsapAnimationsForElement, +} from "./gsapKeyframeCacheHelpers"; +import { idFromSelector, toClipKeyframes } from "./gsapShared"; +import { + deduplicateKeyframes, + isStaticPositionHold, + synthesizeFlatTweenKeyframes, +} from "./gsapTweenSynth"; + +/** + * Resolve a tween's target selector to the ids of the element(s) it animates. + * A bare `#id` resolves directly; anything else (a class like `.dot`, a group + * `.a, .b`, or a descendant selector) is matched against the live preview DOM so + * class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every + * element they animate — not just one parsed from the string. Falls back to a + * leading `#id` when there's no DOM (so the cache still populates pre-iframe). + */ +// fallow-ignore-next-line complexity +export function resolveSelectorElementIds( + selector: string, + doc: Document | null | undefined, +): string[] { + // A whole-selector id match (either shape) addresses exactly one element. + const bareId = /^(#[\w-]+|\[id="(?:\\.|[^"\\])*"\])$/.test(selector) + ? idFromSelector(selector) + : null; + if (bareId) return [bareId]; + if (!doc) { + const lead = idFromSelector(selector); + return lead ? [lead] : []; + } + const ids = new Set(); + for (const part of selector.split(",")) { + const sel = part.trim(); + if (!sel) continue; + try { + for (const el of Array.from(doc.querySelectorAll(sel))) { + if (el.id) ids.add(el.id); + } + } catch { + const lead = idFromSelector(sel); + if (lead) ids.add(lead); + } + } + return Array.from(ids); +} +/** + * The slice of the parse response callers actually read. The endpoint returns + * the full `ParsedGsap` (preamble/postamble and all), but nothing downstream of + * this fetch touches the source-text fields, so the guard below only has to + * vouch for what gets used. + */ +type ParsedGsapAnimations = Pick< + ParsedGsap, + "animations" | "multipleTimelines" | "unsupportedTimelinePattern" +>; + +/** + * A proxy, an error page, or a stale server can answer 200 with something that + * has no `animations` array — the case where the old blind cast crashed on + * `.animations.filter`. + */ +function hasAnimations(value: unknown): value is ParsedGsapAnimations { + return ( + typeof value === "object" && + value !== null && + "animations" in value && + Array.isArray(value.animations) + ); +} + +export async function fetchParsedAnimations( + projectId: string, + sourceFile: string, +): Promise { + try { + const res = await fetch( + `/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`, + // Always re-read the freshly-parsed source; no per-call timestamp (which + // would defeat caching forever and is a deterministic-render no-no). + { cache: "no-store" }, + ); + if (!res.ok) return null; + const parsed: unknown = await res.json(); + if (!hasAnimations(parsed)) return null; + // Studio-emitted pre-keyframe hold `set`s are an internal runtime detail (they + // hold an element's first keyframe before its tween). They must not surface as + // user animations — otherwise they pollute the keyframe cache / timeline diamonds. + return { ...parsed, animations: parsed.animations.filter((a) => !isStudioHoldSet(a)) }; + } catch { + return null; + } +} + +/** + * Clip-relative timing basis for an element. Sub-composition internals (e.g. pills + * inside a scene) aren't timeline clips themselves — they're derived at expand time + * — so they're absent from `elements`. Without a basis, elDuration defaulted to 1 + * and clip-relative keyframe percentages blew past 100% (rendering off the clip). + * Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's + * data-composition-src is stripped in the rendered DOM, so we can't query it). + */ +export function resolveClipTimingBasis( + elementId: string, + sourceFile: string, + elements: ReadonlyArray<{ + domId?: string; + key?: string; + id: string; + start: number; + duration: number; + }>, + domClipChildren: ReadonlyArray<{ id: string; hostId: string }>, +): { elStart: number; elDuration: number } { + const direct = elements.find( + (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`, + ); + if (direct) return { elStart: direct.start, elDuration: direct.duration }; + const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId; + const host = hostId + ? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`) + : undefined; + return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 }; +} + +/** + * Read one composition file's tweens into the keyframe cache. Split out of the + * hook so the effect can run it per file without re-nesting the whole body. + */ +// fallow-ignore-next-line complexity +export async function populateKeyframeCacheFromAst( + projectId: string, + sf: string, + doc: Document | null | undefined, +): Promise { + const parsed = await fetchParsedAnimations(projectId, sf); + if (!parsed) return; + const { setKeyframeCache } = usePlayerStore.getState(); + clearKeyframeCacheForFile(sf); + const { elements, domClipChildren } = usePlayerStore.getState(); + const mergedByElement = new Map(); + const sourceByElement = new Map(); + for (const anim of parsed.animations) { + if (anim.hasUnresolvedKeyframes) continue; + if (isStaticPositionHold(anim)) continue; + const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim); + if (!kfData) continue; + // Attribute the tween to every element it animates (handles class / + // group / descendant selectors, not just `#id`). + for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) { + // kfData is already resolved (real keyframes OR a synthesized flat + // tween), so a flat tween joins the store like a keyframed one. No + // property-group filter: this map must cover every tween the cache + // below records, or expanded lanes have nothing to render. + sourceByElement.set(id, [...(sourceByElement.get(id) ?? []), anim]); + const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren); + const clipKeyframes = toClipKeyframes(kfData.keyframes, anim, elStart, elDuration); + const existing = mergedByElement.get(id); + if (existing) { + existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]); + } else { + mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes }); + } + } + } + for (const [id, kfData] of mergedByElement) { + for (const key of elementCacheKeys(sf, id)) setKeyframeCache(key, kfData); + writeGsapAnimationsForElement(sf, id, sourceByElement.get(id)); + } +} diff --git a/packages/studio/src/hooks/timelineEditingHelpers.test.ts b/packages/studio/src/hooks/timelineEditingHelpers.test.ts index 5ac3b69204..7d0e799172 100644 --- a/packages/studio/src/hooks/timelineEditingHelpers.test.ts +++ b/packages/studio/src/hooks/timelineEditingHelpers.test.ts @@ -12,6 +12,7 @@ import { import type { TimelineElement } from "../player/store/playerStore"; import { usePlayerStore } from "../player/store/playerStore"; import type { CommitMutationOptions } from "./gsapScriptCommitTypes"; +import { timelineKeyframeSelectionKey } from "../player/components/timelineKeyframeIdentity"; afterEach(() => { usePlayerStore.getState().reset(); @@ -342,4 +343,83 @@ describe("deleteSelectedKeyframes", () => { expect(options[1]).not.toHaveProperty("softReload"); expect(options[2]).not.toHaveProperty("skipReload"); }); + + it("deletes two expanded lanes through their own animation and tween percentages", () => { + usePlayerStore.setState({ + selectedElementId: "card", + selectedKeyframes: new Set([ + timelineKeyframeSelectionKey("card", { + percentage: 30, + tweenPercentage: 20, + propertyGroup: "position", + animationId: "card-position", + }), + timelineKeyframeSelectionKey("card", { + percentage: 70, + tweenPercentage: 80, + propertyGroup: "visual", + animationId: "card-visual", + }), + ]), + }); + const handleGsapRemoveKeyframe = + vi.fn<(animId: string, pct: number, options?: Partial) => void>(); + + deleteSelectedKeyframes({ + selectedGsapAnimations: [ + { id: "card-position", keyframes: {} }, + { id: "card-visual", keyframes: {} }, + ], + handleGsapRemoveKeyframe, + }); + + expect(handleGsapRemoveKeyframe).toHaveBeenCalledTimes(2); + expect( + handleGsapRemoveKeyframe.mock.calls.map(([animationId, percentage]) => [ + animationId, + percentage, + ]), + ).toEqual([ + ["card-position", 20], + ["card-visual", 80], + ]); + expect(handleGsapRemoveKeyframe.mock.calls[0]?.[2]).toEqual( + expect.objectContaining({ skipReload: true }), + ); + expect(handleGsapRemoveKeyframe.mock.calls[1]?.[2]).toEqual( + expect.objectContaining({ softReload: true }), + ); + }); + + it("drops keyframes that belong to other elements", () => { + // A stale selection from a previously active element must not delete + // anything on the element that is active now. + usePlayerStore.setState({ + selectedElementId: "card", + selectedKeyframes: new Set([ + timelineKeyframeSelectionKey("card", { + percentage: 30, + tweenPercentage: 20, + propertyGroup: "position", + animationId: "card-position", + }), + timelineKeyframeSelectionKey("other", { + percentage: 70, + tweenPercentage: 80, + propertyGroup: "position", + animationId: "card-position", + }), + ]), + }); + const handleGsapRemoveKeyframe = + vi.fn<(animId: string, pct: number, options?: Partial) => void>(); + + deleteSelectedKeyframes({ + selectedGsapAnimations: [{ id: "card-position", keyframes: {} }], + handleGsapRemoveKeyframe, + }); + + expect(handleGsapRemoveKeyframe).toHaveBeenCalledTimes(1); + expect(handleGsapRemoveKeyframe.mock.calls[0]?.[1]).toBe(20); + }); }); diff --git a/packages/studio/src/hooks/timelineMoveAdapter.ts b/packages/studio/src/hooks/timelineMoveAdapter.ts index f5b9b2fb65..ee7544e8ea 100644 --- a/packages/studio/src/hooks/timelineMoveAdapter.ts +++ b/packages/studio/src/hooks/timelineMoveAdapter.ts @@ -4,7 +4,7 @@ import type { TimelineGroupMoveChange, } from "./useTimelineGroupEditing"; -interface MoveEdit { +export interface TimelineMoveEdit { element: TimelineElement; updates: Pick; } @@ -18,8 +18,15 @@ interface AtomicMoveDeps { export type TimelineMoveOperation = "timing" | "lane-reorder" | "track-insert"; +export type TimelineMoveEditsHandler = ( + edits: TimelineMoveEdit[], + coalesceKey?: string, + operation?: TimelineMoveOperation, + coalesceMs?: number, +) => Promise; + export function persistTimelineMoveEditsAtomically( - edits: MoveEdit[], + edits: TimelineMoveEdit[], coalesceKey: string | undefined, operation: TimelineMoveOperation, deps: AtomicMoveDeps, diff --git a/packages/studio/src/hooks/useDomEditWiring.ts b/packages/studio/src/hooks/useDomEditWiring.ts index 19e21d29b6..b9468d510a 100644 --- a/packages/studio/src/hooks/useDomEditWiring.ts +++ b/packages/studio/src/hooks/useDomEditWiring.ts @@ -92,14 +92,14 @@ export interface UseDomEditWiringParams { animId: string, fromPercentage: number, toPercentage: number, - ) => void; + ) => Promise; resizeKeyframedTween: ( sel: DomEditSelection, animId: string, position: number, duration: number, pctRemap: Array<{ from: number; to: number }>, - ) => void; + ) => Promise; convertToKeyframes: ( sel: DomEditSelection, animId: string, diff --git a/packages/studio/src/hooks/useDomSelection.ts b/packages/studio/src/hooks/useDomSelection.ts index 903894c7e9..568c964228 100644 --- a/packages/studio/src/hooks/useDomSelection.ts +++ b/packages/studio/src/hooks/useDomSelection.ts @@ -24,6 +24,7 @@ import { type DomEditSelection, } from "../components/editor/domEditing"; import { reapplyPositionEditsAfterSeek } from "../components/editor/manualEdits"; +import { useStudioTestHooks } from "./useStudioTestHooks"; // ── Types ── @@ -506,6 +507,9 @@ export function useDomSelection({ applyDomSelection(null, { revealPanel: false }); }, [applyDomSelection, captionEditMode]); + // Dev-only headless-QA shortcut (window.__studioTest.selectByDomId). No-op in prod. + useStudioTestHooks({ previewIframeRef, buildDomSelectionFromTarget, applyDomSelection }); + const applyMarqueeSelection = useCallback( // fallow-ignore-next-line complexity (selections: DomEditSelection[], additive: boolean) => { diff --git a/packages/studio/src/hooks/useEnableKeyframes.test.ts b/packages/studio/src/hooks/useEnableKeyframes.test.ts index 3967edf518..f9cac5e9ec 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.test.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { + applyArcKeyframeAtPlayhead, animatedProps, buildExtendedKeyframes, isPlayheadWithinTween, @@ -107,6 +108,19 @@ describe("isPlayheadWithinTween", () => { it("does not block when the start can't be resolved", () => { expect(isPlayheadWithinTween(anim({ position: "+=1" }), 99)).toBe(true); }); + + // The toolbar's "extends animation" tooltip has to agree with what the edit + // paths do. Those span a duration-less tween across its clip, so answering + // from GSAP's 0.5s default reported the playhead outside a window the click + // then treated as clip-wide. + it("spans the clip for a duration-less tween when given the selection", () => { + const durationless = anim({ position: 0 }); + const selection = { dataAttributes: { duration: "16" } } as unknown as DomEditSelection; + + expect(isPlayheadWithinTween(durationless, 5)).toBe(false); + expect(isPlayheadWithinTween(durationless, 5, selection)).toBe(true); + expect(isPlayheadWithinTween(durationless, 20, selection)).toBe(false); + }); }); describe("buildExtendedKeyframes", () => { @@ -219,6 +233,114 @@ describe("promoteSetToKeyframes — auto endpoint", () => { }); }); +describe("applyArcKeyframeAtPlayhead", () => { + const arcAnim = anim({ + id: "#el-to-0-position", + position: 0, + duration: 10, + keyframes: { + format: "object-array", + keyframes: [ + { percentage: 0, properties: { x: 0, y: 0 } }, + { percentage: 50, properties: { x: 50, y: 50 } }, + { percentage: 100, properties: { x: 100, y: 0 } }, + ], + }, + arcPath: { + enabled: true, + autoRotate: false, + segments: [{ curviness: 1 }, { curviness: 1 }], + }, + }); + + function arcFixture(x: number, y: number) { + const commitMutation = vi.fn(async () => undefined); + const session = { commitMutation } as unknown as EnableKeyframesSession; + const sel = { + id: "el", + selector: "#el", + element: { isConnected: true } as HTMLElement, + dataAttributes: { duration: "10" }, + } as DomEditSelection; + const iframe = { + contentWindow: { + gsap: { getProperty: (_element: Element, property: string) => (property === "x" ? x : y) }, + }, + } as unknown as HTMLIFrameElement; + return { commitMutation, iframe, sel, session }; + } + + it("removes an existing interior stop without redistributing the remaining times", async () => { + const fixture = arcFixture(50, 50); + await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 5, fixture.iframe); + expect(fixture.commitMutation).toHaveBeenCalledWith( + { + type: "replace-with-keyframes", + animationId: arcAnim.id, + targetSelector: "#el", + position: 0, + duration: 10, + keyframes: [ + { percentage: 0, properties: { x: 0, y: 0 } }, + { percentage: 100, properties: { x: 100, y: 0 } }, + ], + ease: "none", + }, + { label: "Remove keyframe", softReload: true }, + ); + }); + + it("preserves the path endpoints", async () => { + const fixture = arcFixture(0, 0); + await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 0, fixture.iframe); + expect(fixture.commitMutation).not.toHaveBeenCalled(); + }); + + it("adds a temporal keyframe at the exact playhead while preserving authored times", async () => { + const fixture = arcFixture(25, 25); + await applyArcKeyframeAtPlayhead(fixture.session, fixture.sel, arcAnim, 2.5, fixture.iframe); + expect(fixture.commitMutation).toHaveBeenCalledWith( + { + type: "replace-with-keyframes", + animationId: arcAnim.id, + targetSelector: "#el", + position: 0, + duration: 10, + keyframes: [ + { percentage: 0, properties: { x: 0, y: 0 } }, + { percentage: 25, properties: { x: 25, y: 25 } }, + { percentage: 50, properties: { x: 50, y: 50 } }, + { percentage: 100, properties: { x: 100, y: 0 } }, + ], + ease: "none", + }, + { label: "Add keyframe", softReload: true }, + ); + }); + + it("uses the owning clip duration when an arc omits its outer duration", async () => { + const fixture = arcFixture(25, 25); + const durationlessArc = { ...arcAnim, duration: undefined }; + + await applyArcKeyframeAtPlayhead( + fixture.session, + fixture.sel, + durationlessArc, + 2.5, + fixture.iframe, + ); + + expect(fixture.commitMutation).toHaveBeenCalledWith( + expect.objectContaining({ + type: "replace-with-keyframes", + duration: 10, + keyframes: expect.arrayContaining([{ percentage: 25, properties: { x: 25, y: 25 } }]), + }), + { label: "Add keyframe", softReload: true }, + ); + }); +}); + function renderEnableKeyframes(session: EnableKeyframesSession): () => Promise { let enable: (() => Promise) | null = null; function Probe() { diff --git a/packages/studio/src/hooks/useEnableKeyframes.ts b/packages/studio/src/hooks/useEnableKeyframes.ts index 805af7bcb6..38c8f50086 100644 --- a/packages/studio/src/hooks/useEnableKeyframes.ts +++ b/packages/studio/src/hooks/useEnableKeyframes.ts @@ -12,16 +12,23 @@ import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gs import type { DomEditSelection } from "../components/editor/domEditingTypes"; import { usePlayerStore } from "../player/store/playerStore"; import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache"; -import { selectorFromSelection, computeElementPercentage, isInstantHold } from "./gsapShared"; import { + selectorFromSelection, + computeElementPercentage, + KEYFRAME_PCT_MATCH, + isInstantHold, + resolveEditableTweenDuration, +} from "./gsapShared"; +import { + absoluteToPercentage, resolveTweenStart, resolveTweenDuration, isTimeWithinTween, } from "../utils/globalTimeCompiler"; import { POSITION_PROPS } from "./gsapRuntimeReaders"; import { roundTo3 } from "../utils/rounding"; -import { nearestPointOnPath } from "../components/editor/motionPathGeometry"; import type { CommitMutationOptions } from "./gsapScriptCommitTypes"; +import { buildTemporalArcKeyframes } from "./gsapDragPositionCommit"; let enableKeyframesTransactionCounter = 0; @@ -71,11 +78,22 @@ export function animatedProps(anim: GsapAnimation | null): string[] { * Whether the playhead sits inside an animation's tween range. When the tween's * start can't be resolved we don't block (the percentage falls back to clip range, * preserving prior behavior for elements without explicit timing). + * + * Pass the selection whenever the caller has one: a duration-less tween spans + * its clip, and answering from GSAP's 0.5s default reports the playhead outside + * a window the edit paths treat as clip-wide. */ -export function isPlayheadWithinTween(anim: GsapAnimation, currentTime: number): boolean { +export function isPlayheadWithinTween( + anim: GsapAnimation, + currentTime: number, + selection?: DomEditSelection | null, +): boolean { const start = resolveTweenStart(anim); if (start === null) return true; - return isTimeWithinTween(currentTime, start, resolveTweenDuration(anim)); + const duration = selection + ? resolveEditableTweenDuration(anim, selection) + : resolveTweenDuration(anim); + return isTimeWithinTween(currentTime, start, duration); } /** @@ -89,9 +107,10 @@ export function buildExtendedKeyframes( anim: GsapAnimation, currentTime: number, position: Record, + sourceDuration = resolveTweenDuration(anim), ): { position: number; duration: number; keyframes: GsapPercentageKeyframe[] } { const oldStart = resolveTweenStart(anim) ?? 0; - const oldDuration = resolveTweenDuration(anim); + const oldDuration = sourceDuration; const newStart = Math.min(oldStart, currentTime); const newEnd = Math.max(oldStart + oldDuration, currentTime); const newDuration = roundTo3(newEnd - newStart); @@ -222,6 +241,37 @@ async function fetchAnimationsForElement(sel: DomEditSelection): Promise, +): Promise { + const selector = selectorFromSelection(sel); + const position = readElementPosition(iframe, sel, anim); + if (!selector || Object.keys(position).length === 0 || !session.commitMutation) return; + const extended = buildExtendedKeyframes(anim, currentTime, position, duration); + await session.commitMutation( + { + type: "replace-with-keyframes", + animationId: anim.id, + targetSelector: selector, + position: extended.position, + duration: extended.duration, + keyframes: extended.keyframes, + ease: anim.ease, + }, + { + label: "Add keyframe", + softReload: true, + ...commitOverrides, + }, + ); +} + /** * Apply "add keyframe at playhead" to a tween that already has x/y keyframes: * toggle off an existing stop, add one at the playhead's tween-relative %, or — @@ -237,32 +287,25 @@ async function applyKeyframeAtPlayhead( iframe: HTMLIFrameElement | null, commitOverrides?: Partial, ): Promise { - if (!isPlayheadWithinTween(kfAnim, t)) { - const position = readElementPosition(iframe, sel, kfAnim); - const selector = selectorFromSelection(sel); - if (selector && Object.keys(position).length > 0 && session.commitMutation) { - const extended = buildExtendedKeyframes(kfAnim, t, position); - await session.commitMutation( - { - type: "replace-with-keyframes", - animationId: kfAnim.id, - targetSelector: selector, - position: extended.position, - duration: extended.duration, - keyframes: extended.keyframes, - ease: kfAnim.ease, - }, - { - label: "Add keyframe", - softReload: true, - ...commitOverrides, - }, - ); - } + const duration = resolveEditableTweenDuration(kfAnim, sel); + const start = resolveTweenStart(kfAnim); + if (start !== null && !isTimeWithinTween(t, start, duration)) { + await extendKeyframedTweenToPlayhead( + session, + sel, + kfAnim, + t, + duration, + iframe, + commitOverrides, + ); return; } - const pct = computeElementPercentage(t, sel, kfAnim); - const existing = kfAnim.keyframes?.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1); + const pct = + start === null ? computeElementPercentage(t, sel) : absoluteToPercentage(t, start, duration); + const existing = kfAnim.keyframes?.keyframes.find( + (k) => Math.abs(k.percentage - pct) <= KEYFRAME_PCT_MATCH, + ); if (existing) { session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage); return; @@ -332,14 +375,13 @@ export async function promoteSetToKeyframes( } /** - * An arc (motionPath) tween — its waypoints are reconstructed onto `keyframes`, so - * it must be edited as waypoints (not x/y keyframes, which would break the curve). - * "Add keyframe at playhead" drops a waypoint where the element currently sits on - * the path, inserted at the matching segment so the curve is preserved. Outside the - * range, extend the duration so the motion reaches the playhead. + * Convert an arc (motionPath) tween to temporal x/y keyframes before toggling the + * playhead stop. A toolbar command named "Add keyframe at playhead" must preserve + * every authored stop's time; inserting a spatial waypoint instead redistributes + * the path and can silently compress the animation. */ // fallow-ignore-next-line complexity -async function applyArcWaypointAtPlayhead( +export async function applyArcKeyframeAtPlayhead( session: EnableKeyframesSession, sel: DomEditSelection, arcAnim: GsapAnimation, @@ -347,8 +389,11 @@ async function applyArcWaypointAtPlayhead( iframe: HTMLIFrameElement | null, ): Promise { if (!session.commitMutation) return; - if (!isPlayheadWithinTween(arcAnim, t)) { - const start = resolveTweenStart(arcAnim) ?? 0; + const targetSelector = selectorFromSelection(sel); + if (!targetSelector) return; + const start = resolveTweenStart(arcAnim) ?? 0; + const duration = resolveEditableTweenDuration(arcAnim, sel); + if (!isTimeWithinTween(t, start, duration)) { if (t > start) { await session.commitMutation( { @@ -361,30 +406,45 @@ async function applyArcWaypointAtPlayhead( } return; } + const nodes = arcAnim.keyframes?.keyframes ?? []; + const playheadPercentage = absoluteToPercentage(t, start, duration); + const timedNodeIndex = nodes.findIndex( + (node) => Math.abs(node.percentage - playheadPercentage) <= KEYFRAME_PCT_MATCH, + ); + if (timedNodeIndex !== -1) { + if (timedNodeIndex > 0 && timedNodeIndex < nodes.length - 1) { + await session.commitMutation( + { + type: "replace-with-keyframes", + animationId: arcAnim.id, + targetSelector, + position: roundTo3(start), + duration: roundTo3(duration), + keyframes: nodes.filter((_, index) => index !== timedNodeIndex), + ease: "none", + }, + { label: "Remove keyframe", softReload: true }, + ); + } + return; + } + const live = readElementPosition(iframe, sel, arcAnim); if (typeof live.x !== "number" || typeof live.y !== "number") return; - const liveX = live.x; - const liveY = live.y; - const nodes = (arcAnim.keyframes?.keyframes ?? []) - .map((k) => ({ x: k.properties.x, y: k.properties.y })) - .filter( - (p): p is { x: number; y: number } => typeof p.x === "number" && typeof p.y === "number", - ); - // Don't duplicate a waypoint that already sits where the element is (e.g. at the - // path endpoints). - const WAYPOINT_MERGE_PX = 6; - if (nodes.some((n) => Math.hypot(n.x - liveX, n.y - liveY) <= WAYPOINT_MERGE_PX)) return; - const proj = nearestPointOnPath(liveX, liveY, nodes); - if (!proj) return; await session.commitMutation( { - type: "add-motion-path-point", + type: "replace-with-keyframes", animationId: arcAnim.id, - index: proj.segIndex + 1, - x: liveX, - y: liveY, + targetSelector, + position: roundTo3(start), + duration: roundTo3(duration), + keyframes: buildTemporalArcKeyframes(arcAnim, playheadPercentage, { + x: live.x, + y: live.y, + }), + ease: "none", }, - { label: "Add waypoint", softReload: true }, + { label: "Add keyframe", softReload: true }, ); } @@ -420,7 +480,7 @@ export function useEnableKeyframes( const flatAnim = anims.find((a) => !a.keyframes && !a.arcPath && !isInstantHold(a)); if (arcAnim) { - await applyArcWaypointAtPlayhead(session, sel, arcAnim, t, iframe); + await applyArcKeyframeAtPlayhead(session, sel, arcAnim, t, iframe); } else if (kfAnim) { await applyKeyframeAtPlayhead(session, sel, kfAnim, t, iframe); } else if (setAnim) { diff --git a/packages/studio/src/hooks/useGestureCommit.ts b/packages/studio/src/hooks/useGestureCommit.ts index a0aba6747f..cdc51dcf97 100644 --- a/packages/studio/src/hooks/useGestureCommit.ts +++ b/packages/studio/src/hooks/useGestureCommit.ts @@ -13,7 +13,7 @@ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { CommitMutationOptions } from "./gsapScriptCommitTypes"; import { roundTo3 } from "../utils/rounding"; import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser"; -import { isInstantHold } from "./gsapShared"; +import { isInstantHold, idSelector } from "./gsapShared"; type RecordedKeyframe = { percentage: number; @@ -168,7 +168,7 @@ export function useGestureCommit({ if (!sortedPcts.includes(0)) sortedPcts.unshift(0); } - const selector = sel.id ? `#${sel.id}` : sel.selector; + const selector = sel.id ? idSelector(sel.id) : sel.selector; if (!selector) { showToast("Cannot save — element has no selector", "error"); return; diff --git a/packages/studio/src/hooks/useGsapAnimationFetchFallback.ts b/packages/studio/src/hooks/useGsapAnimationFetchFallback.ts index e1fcded7d2..4e6f6ef9bc 100644 --- a/packages/studio/src/hooks/useGsapAnimationFetchFallback.ts +++ b/packages/studio/src/hooks/useGsapAnimationFetchFallback.ts @@ -36,7 +36,7 @@ export type ElementAnimationsOutcome = * so the caller can apply the right retry budget to each. */ export function selectElementAnimationsOrRetry( - parsed: ParsedGsap | null, + parsed: Pick | null, target: { id: string | null; selector: string | null }, ): ElementAnimationsOutcome { if (!parsed) return { kind: "fetch-error" }; diff --git a/packages/studio/src/hooks/useGsapKeyframeOps.test.tsx b/packages/studio/src/hooks/useGsapKeyframeOps.test.tsx index e697ab6127..6017353d08 100644 --- a/packages/studio/src/hooks/useGsapKeyframeOps.test.tsx +++ b/packages/studio/src/hooks/useGsapKeyframeOps.test.tsx @@ -19,11 +19,21 @@ afterEach(() => { const selection: DomEditSelection = { id: "box", selector: "#box" } as DomEditSelection; +function successfulCommitMutation() { + return vi.fn<(...args: unknown[]) => Promise>(async (...args) => { + const options = args[2] as { + onResult?: (result: { ok: boolean; changed: boolean }) => void; + }; + options.onResult?.({ ok: true, changed: true }); + }); +} + function renderKeyframeOps(over: { commitMutation: (...args: unknown[]) => Promise; trackGsapSaveFailure: (...args: unknown[]) => void; }) { const captured: { api: HookApi | null } = { api: null }; + // This hook harness intentionally mirrors the separate script-commit harness. function Probe() { // fallow-ignore-next-line code-duplication captured.api = useGsapKeyframeOps({ @@ -49,11 +59,21 @@ function renderKeyframeOps(over: { return captured.api; } +async function moveKeyframeWith( + commitMutation: (...args: unknown[]) => Promise, +): Promise<{ committed: boolean; trackGsapSaveFailure: ReturnType }> { + const trackGsapSaveFailure = vi.fn(); + const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure }); + let committed = true; + await act(async () => { + committed = await api.moveKeyframe(selection, "box-to-0-position", 50, 75); + }); + return { committed, trackGsapSaveFailure }; +} + describe("useGsapKeyframeOps — resizeKeyframedTween", () => { it("issues a resize-keyframed-tween mutation with the remap + window", async () => { - const commitMutation = vi.fn<(...args: unknown[]) => Promise>(async () => ({ - ok: true, - })); + const commitMutation = successfulCommitMutation(); const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>(); const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure }); @@ -61,8 +81,9 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => { { from: 0, to: 0 }, { from: 100, to: 100 }, ]; + let committed = false; await act(async () => { - api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, pctRemap); + committed = await api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, pctRemap); }); expect(commitMutation).toHaveBeenCalledTimes(1); @@ -76,6 +97,7 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => { pctRemap, }); expect(trackGsapSaveFailure).not.toHaveBeenCalled(); + expect(committed).toBe(true); }); it("routes a rejected commit to trackGsapSaveFailure (no unhandled rejection)", async () => { @@ -86,10 +108,11 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => { const trackGsapSaveFailure = vi.fn<(...args: unknown[]) => void>(); const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure }); + let committed = true; await act(async () => { - api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, [{ from: 100, to: 100 }]); - // let the rejected commit promise settle inside act - await Promise.resolve(); + committed = await api.resizeKeyframedTween(selection, "box-to-0-opacity", 0.2, 2, [ + { from: 100, to: 100 }, + ]); }); expect(trackGsapSaveFailure).toHaveBeenCalledTimes(1); @@ -98,14 +121,72 @@ describe("useGsapKeyframeOps — resizeKeyframedTween", () => { expect(selArg).toBe(selection); expect((mutationArg as { type: string }).type).toBe("resize-keyframed-tween"); expect(labelArg).toBe("Retime keyframe (resize tween)"); + expect(committed).toBe(false); + }); +}); + +describe("useGsapKeyframeOps — moveKeyframe settlement", () => { + it("returns false when the commit settles without a durable writer result", async () => { + const { committed, trackGsapSaveFailure } = await moveKeyframeWith(vi.fn(async () => {})); + + expect(committed).toBe(false); + expect(trackGsapSaveFailure).not.toHaveBeenCalled(); + }); + + it("returns false when the writer accepts but does not change the keyframe", async () => { + const commitMutation = vi.fn(async (...args: unknown[]) => { + const options = args[2] as { onResult?: (result: { ok: boolean; changed: boolean }) => void }; + options.onResult?.({ ok: true, changed: false }); + }); + const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation); + + expect(committed).toBe(false); + expect(trackGsapSaveFailure).not.toHaveBeenCalled(); + }); + + it("returns false and tracks a rejected move", async () => { + const error = new Error("write failed"); + const commitMutation = vi.fn().mockRejectedValue(error); + const { committed, trackGsapSaveFailure } = await moveKeyframeWith(commitMutation); + + expect(committed).toBe(false); + expect(trackGsapSaveFailure).toHaveBeenCalledExactlyOnceWith( + error, + selection, + { + type: "move-keyframe", + animationId: "box-to-0-position", + fromPercentage: 50, + toPercentage: 75, + }, + "Move keyframe to 75%", + ); }); }); describe("useGsapKeyframeOps — keyframe transaction options", () => { + it("routes a flat-lane add through the add-keyframe writer mutation", async () => { + const commitMutation = successfulCommitMutation(); + const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() }); + + await act(async () => { + await api.addKeyframeBatch(selection, "box-to-0-position", 50, { x: 210 }); + }); + + expect(commitMutation).toHaveBeenCalledWith( + selection, + { + type: "add-keyframe", + animationId: "box-to-0-position", + percentage: 50, + properties: { x: 210 }, + }, + { label: "Add keyframe at 50%", softReload: true }, + ); + }); + it("soft-reloads a standalone convert when the SDK path is unavailable", async () => { - const commitMutation = vi.fn<(...args: unknown[]) => Promise>(async () => ({ - ok: true, - })); + const commitMutation = successfulCommitMutation(); const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() }); await act(async () => { @@ -123,9 +204,7 @@ describe("useGsapKeyframeOps — keyframe transaction options", () => { }); it("threads one coalesce key through skipped convert reload and terminal batch edit", async () => { - const commitMutation = vi.fn<(...args: unknown[]) => Promise>(async () => ({ - ok: true, - })); + const commitMutation = successfulCommitMutation(); const api = renderKeyframeOps({ commitMutation, trackGsapSaveFailure: vi.fn() }); const coalesceKey = "enable-keyframes:box-to-0-opacity:1"; diff --git a/packages/studio/src/hooks/useGsapKeyframeOps.ts b/packages/studio/src/hooks/useGsapKeyframeOps.ts index 69107c64bd..fe105e644f 100644 --- a/packages/studio/src/hooks/useGsapKeyframeOps.ts +++ b/packages/studio/src/hooks/useGsapKeyframeOps.ts @@ -15,6 +15,7 @@ import { } from "../utils/sdkCutover"; import type { KeyframeCacheEntry } from "../player/store/playerStore"; import { commitKeyframeAtTimeImpl } from "./gsapKeyframeCommit"; +import { idFromSelector } from "./gsapShared"; import { clearKeyframeCacheForElement, readKeyframeSnapshot, @@ -236,7 +237,7 @@ export function useGsapKeyframeOps({ ); const moveKeyframe = useCallback( - ( + async ( selection: DomEditSelection, animationId: string, fromPercentage: number, @@ -247,18 +248,26 @@ export function useGsapKeyframeOps({ // updateKeyframeCacheFromParsed re-keys the diamond from the fresh parse, so no // optimistic cache write is needed (mapping the tween-% to clip-% here would // duplicate that math). softReload mirrors remove-keyframe. - void commitMutation(selection, mutation, { - label: `Move keyframe to ${toPercentage}%`, - softReload: true, - }).catch((error) => { + try { + let changed = false; + await commitMutation(selection, mutation, { + label: `Move keyframe to ${toPercentage}%`, + softReload: true, + onResult: (result) => { + changed = result.changed !== false; + }, + }); + return changed; + } catch (error) { trackGsapSaveFailure(error, selection, mutation, `Move keyframe to ${toPercentage}%`); - }); + return false; + } }, [commitMutation, trackGsapSaveFailure], ); const resizeKeyframedTween = useCallback( - ( + async ( selection: DomEditSelection, animationId: string, position: number, @@ -275,12 +284,20 @@ export function useGsapKeyframeOps({ // Boundary drag-to-retime: the server re-keys keyframes in place + grows the // tween window, preserving _auto / per-keyframe ease / easeEach / outer ease. // softReload re-keys the diamonds from the fresh parse (mirrors moveKeyframe). - void commitMutation(selection, mutation, { - label: "Retime keyframe (resize tween)", - softReload: true, - }).catch((error) => { + try { + let changed = false; + await commitMutation(selection, mutation, { + label: "Retime keyframe (resize tween)", + softReload: true, + onResult: (result) => { + changed = result.changed !== false; + }, + }); + return changed; + } catch (error) { trackGsapSaveFailure(error, selection, mutation, "Retime keyframe (resize tween)"); - }); + return false; + } }, [commitMutation, trackGsapSaveFailure], ); @@ -322,7 +339,7 @@ export function useGsapKeyframeOps({ // remove-all-keyframes collapses the tween to a static hold and the commit // path doesn't return parsed animations, so the keyframe cache is never // refreshed — clear it here so the timeline diamonds disappear immediately. - const elementId = selection.id ?? selection.selector?.match(/^#([\w-]+)/)?.[1] ?? null; + const elementId = selection.id ?? idFromSelector(selection.selector); if (elementId) clearKeyframeCacheForElement(targetPath, elementId); if (sdkSession && sdkDeps) { const handled = await sdkGsapRemoveAllKeyframesPersist( diff --git a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx index 869807eb8a..6236d032ab 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.test.tsx +++ b/packages/studio/src/hooks/useGsapScriptCommits.test.tsx @@ -298,6 +298,27 @@ describe("runCommit — instantPatch wiring", () => { expect(deps.showToast).toHaveBeenCalledWith("A keyframe already exists at that time", "info"); }); + it("publishes the server mutation outcome to callers", async () => { + mockFetchResult({ changed: false }); + const deps = renderCommitHook(); + let commitResult: MutationResult | undefined; + + await act(async () => { + await deps.api.commitMutation( + selection, + { type: "move-keyframe", fromPercentage: 50, toPercentage: 75 }, + { + label: "Move keyframe", + onResult: (result) => { + commitResult = result; + }, + }, + ); + }); + + expect(commitResult).toEqual(expect.objectContaining({ ok: true, changed: false })); + }); + it("no-op commit with an instantPatch still patches the runtime (paired x/y commits)", async () => { patchRuntimeTweenInPlace.mockReturnValue(true); mockFetchResult({ changed: false }); diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index f43d42e91b..566d2bc538 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -338,6 +338,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra mutateGsapScript(pid, targetPath, mutation), ); if (!result) return; + options.onResult?.(result); await finalizeSuccessfulMutation(pid, compositionPath, selection, mutation, targetPath, result, options); }, [showToast, finalizeSuccessfulMutation]); @@ -350,6 +351,7 @@ export function useGsapScriptCommits({ projectIdRef, activeCompPath, previewIfra mutateGsapScriptBatch(pid, targetPath, mutations), ); if (!result) return; + options.onResult?.(result); await finalizeSuccessfulMutation(pid, compositionPath, last.selection, last.mutation, targetPath, result, options); }, [showToast, finalizeSuccessfulMutation]); diff --git a/packages/studio/src/hooks/useGsapSelectionHandlers.test.tsx b/packages/studio/src/hooks/useGsapSelectionHandlers.test.tsx index f1ae419ec9..b20423876a 100644 --- a/packages/studio/src/hooks/useGsapSelectionHandlers.test.tsx +++ b/packages/studio/src/hooks/useGsapSelectionHandlers.test.tsx @@ -2,7 +2,9 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { 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 { useGsapSelectionHandlers } from "./useGsapSelectionHandlers"; (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -37,8 +39,8 @@ function makeParams(overrides: Partial = {}): Params { addKeyframe: vi.fn(), addKeyframeBatch: resolved(), removeKeyframe: vi.fn(), - moveKeyframe: vi.fn(), - resizeKeyframedTween: vi.fn(), + moveKeyframe: vi.fn().mockResolvedValue(true), + resizeKeyframedTween: vi.fn().mockResolvedValue(true), convertToKeyframes: resolved(), removeAllKeyframes: resolved(), handleDomManualEditsReset: vi.fn(), @@ -79,7 +81,11 @@ describe("useGsapSelectionHandlers save failures", () => { makeParams({ updateGsapMeta: vi.fn().mockRejectedValue(error), showToast }), ); - act(() => rendered.handlers().handleGsapUpdateMeta("anim-1", { duration: 2 })); + // Braces, not a bare arrow: the handler returns its settlement promise now, + // and returning a thenable from act() turns it into an un-awaited async act. + act(() => { + void rendered.handlers().handleGsapUpdateMeta("anim-1", { duration: 2 }); + }); await flushRejection(); expect(showToast).toHaveBeenCalledWith("Couldn't save animation: write failed", "error"); @@ -114,3 +120,63 @@ describe("useGsapSelectionHandlers save failures", () => { rendered.unmount(); }); }); + +describe("useGsapSelectionHandlers selection override", () => { + it("aborts on an explicit null override instead of writing to the current selection", () => { + const removeKeyframe = vi.fn(); + const rendered = renderHandlers(makeParams({ removeKeyframe })); + + // Explicit null: the caller resolved a selection for its own element and + // found none, so the write must not land on the selected element. + rendered.handlers().handleGsapRemoveKeyframe("anim-1", 50, undefined, null); + expect(removeKeyframe).not.toHaveBeenCalled(); + + // Omitted override: falls back to the current selection as before. + rendered.handlers().handleGsapRemoveKeyframe("anim-1", 50); + expect(removeKeyframe).toHaveBeenCalledOnce(); + rendered.unmount(); + }); + + it("computes the playhead percentage from the passed animation, not the selection's", () => { + const moveKeyframe = vi.fn(); + const selection = makeSelection(); + // The passed tween runs 2s→6s, so the playhead at 3s is 25% into IT. Without + // the animation the handler falls back to the selection's own element window + // (0s→1s here), which reads the same playhead as 100%. Asserting the exact + // 25 is what separates the two; `expect.any(Number)` even accepts the NaN a + // missing window would produce. + const animation = { + id: "anim-1", + position: 2, + resolvedStart: 2, + duration: 4, + keyframes: { keyframes: [] }, + } as unknown as GsapAnimation; + usePlayerStore.setState({ currentTime: 3 }); + const rendered = renderHandlers(makeParams({ moveKeyframe, selectedGsapAnimations: [] })); + + rendered.handlers().handleGsapMoveKeyframeToPlayhead("anim-1", 50, selection, animation); + + expect(moveKeyframe).toHaveBeenCalledWith(selection, "anim-1", 50, 25); + rendered.unmount(); + }); +}); + +describe("useGsapSelectionHandlers retime settlement", () => { + it("returns false without a selection and forwards the mutation result with one", async () => { + const moveKeyframe = vi.fn().mockResolvedValue(true); + const withoutSelection = renderHandlers(makeParams({ domEditSelection: null, moveKeyframe })); + await expect( + withoutSelection.handlers().handleGsapMoveKeyframe("anim-1", 50, 75), + ).resolves.toBe(false); + expect(moveKeyframe).not.toHaveBeenCalled(); + withoutSelection.unmount(); + + const withSelection = renderHandlers(makeParams({ moveKeyframe })); + await expect(withSelection.handlers().handleGsapMoveKeyframe("anim-1", 50, 75)).resolves.toBe( + true, + ); + expect(moveKeyframe).toHaveBeenCalledOnce(); + withSelection.unmount(); + }); +}); diff --git a/packages/studio/src/hooks/useGsapSelectionHandlers.ts b/packages/studio/src/hooks/useGsapSelectionHandlers.ts index 0b11d760de..c7579219eb 100644 --- a/packages/studio/src/hooks/useGsapSelectionHandlers.ts +++ b/packages/studio/src/hooks/useGsapSelectionHandlers.ts @@ -94,14 +94,14 @@ export function useGsapSelectionHandlers({ animId: string, fromPercentage: number, toPercentage: number, - ) => void; + ) => Promise; resizeKeyframedTween: ( sel: DomEditSelection, animId: string, position: number, duration: number, pctRemap: Array<{ from: number; to: number }>, - ) => void; + ) => Promise; convertToKeyframes: ( sel: DomEditSelection, animId: string, @@ -118,6 +118,19 @@ export function useGsapSelectionHandlers({ const lastSelectionRef = useRef(null); if (domEditSelection) lastSelectionRef.current = domEditSelection; + // `undefined` means the caller passed no override and accepts the current + // selection. An explicit `null` means the caller RESOLVED a selection for the + // element it is editing and there is none: falling back to domEditSelection + // there commits the edit onto whichever element happens to be selected, which + // is a different element's file. Only `undefined` may fall back. + const resolveWriteSelection = useCallback( + (selectionOverride?: DomEditSelection | null): DomEditSelection | null => + selectionOverride === undefined + ? (domEditSelection ?? lastSelectionRef.current) + : selectionOverride, + [domEditSelection], + ); + const trackGsapHandlerFailure = useCallback( (error: unknown, selection: DomEditSelection, mutationType: string, label: string) => { trackStudioSaveFailure({ @@ -137,12 +150,24 @@ export function useGsapSelectionHandlers({ [showToast], ); + // Resolves to whether the mutation landed. Callers that only fire-and-forget + // can ignore it (the rejection is always handled here), but a caller that + // reports a commit result to the UI has to await the real settlement instead + // of assuming success the moment it dispatched. const observeGsapMutation = useCallback( - (mutation: Promise, selection: DomEditSelection, mutationType: string, label: string) => { - void mutation.catch((error) => { - trackGsapHandlerFailure(error, selection, mutationType, label); - }); - }, + ( + mutation: Promise, + selection: DomEditSelection, + mutationType: string, + label: string, + ): Promise => + mutation.then( + () => true, + (error: unknown) => { + trackGsapHandlerFailure(error, selection, mutationType, label); + return false; + }, + ), [trackGsapHandlerFailure], ); @@ -160,25 +185,25 @@ export function useGsapSelectionHandlers({ updates: { duration?: number; ease?: string; position?: number }, selectionOverride?: DomEditSelection | null, ) => { - const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current; - if (!sel) return; - observeGsapMutation( + const sel = resolveWriteSelection(selectionOverride); + if (!sel) return Promise.resolve(false); + return observeGsapMutation( updateGsapMeta(sel, animId, updates), sel, "update-meta", "Edit GSAP animation", ); }, - [domEditSelection, observeGsapMutation, updateGsapMeta], + [resolveWriteSelection, observeGsapMutation, updateGsapMeta], ); const handleGsapDeleteAnimation = useCallback( - (animId: string) => { - const sel = domEditSelection ?? lastSelectionRef.current; + (animId: string, selectionOverride?: DomEditSelection | null) => { + const sel = resolveWriteSelection(selectionOverride); if (!sel) return; observeGsapMutation(deleteGsapAnimation(sel, animId), sel, "delete", "Delete GSAP animation"); }, - [domEditSelection, deleteGsapAnimation, observeGsapMutation], + [resolveWriteSelection, deleteGsapAnimation, observeGsapMutation], ); const handleGsapDeleteAllForElement = useCallback( @@ -284,12 +309,12 @@ export function useGsapSelectionHandlers({ value: number | string, selectionOverride?: DomEditSelection | null, ) => { - const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current; + const sel = resolveWriteSelection(selectionOverride); if (!sel) return; trackStudioEvent("keyframe", { action: "add", property }); addKeyframe(sel, animId, percentage, property, value); }, - [domEditSelection, addKeyframe], + [resolveWriteSelection, addKeyframe], ); const handleGsapAddKeyframeBatch = useCallback( @@ -298,19 +323,17 @@ export function useGsapSelectionHandlers({ percentage: number, properties: Record, commitOverrides?: Partial, + selectionOverride?: DomEditSelection | null, ) => { - if (!domEditSelection) return Promise.resolve(); - return addKeyframeBatch( - domEditSelection, - animId, - percentage, - properties, - commitOverrides, - ).catch((error) => { - trackGsapHandlerFailure(error, domEditSelection, "add-keyframe", "Add keyframe"); - }); + const sel = resolveWriteSelection(selectionOverride); + if (!sel) return Promise.resolve(); + return addKeyframeBatch(sel, animId, percentage, properties, commitOverrides).catch( + (error) => { + trackGsapHandlerFailure(error, sel, "add-keyframe", "Add keyframe"); + }, + ); }, - [domEditSelection, addKeyframeBatch, trackGsapHandlerFailure], + [resolveWriteSelection, addKeyframeBatch, trackGsapHandlerFailure], ); const handleGsapRemoveKeyframe = useCallback( ( @@ -319,26 +342,34 @@ export function useGsapSelectionHandlers({ commitOverrides?: Partial, selectionOverride?: DomEditSelection | null, ) => { - const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current; + const sel = resolveWriteSelection(selectionOverride); if (!sel) return; trackStudioEvent("keyframe", { action: "remove" }); removeKeyframe(sel, animId, percentage, commitOverrides); }, - [domEditSelection, removeKeyframe], + [resolveWriteSelection, removeKeyframe], ); const handleGsapMoveKeyframeToPlayhead = useCallback( - (animId: string, fromPercentage: number, selectionOverride?: DomEditSelection | null) => { - const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current; + ( + animId: string, + fromPercentage: number, + selectionOverride?: DomEditSelection | null, + animationOverride?: GsapAnimation, + ) => { + const sel = resolveWriteSelection(selectionOverride); if (!sel) return; // Retime the keyframe to the playhead, preserving its value + ease. The - // playhead's tween-relative percentage is the move target. - const anim = selectedGsapAnimations.find((a) => a.id === animId); + // playhead's tween-relative percentage is the move target, and it has to + // come from the SAME element the write lands on: reading the animation off + // the current selection while the percentage came from the clicked element + // computes the target against one tween and writes it into another. + const anim = animationOverride ?? selectedGsapAnimations.find((a) => a.id === animId); const toPercentage = computeCurrentPercentage(sel, anim); trackStudioEvent("keyframe", { action: "move_to_playhead" }); - moveKeyframe(sel, animId, fromPercentage, toPercentage); + void moveKeyframe(sel, animId, fromPercentage, toPercentage); }, - [domEditSelection, selectedGsapAnimations, moveKeyframe], + [resolveWriteSelection, selectedGsapAnimations, moveKeyframe], ); const handleGsapMoveKeyframe = useCallback( @@ -348,16 +379,16 @@ export function useGsapSelectionHandlers({ toPercentage: number, selectionOverride?: DomEditSelection | null, ) => { - const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current; - if (!sel) return; + const sel = resolveWriteSelection(selectionOverride); + if (!sel) return Promise.resolve(false); // Atomic retime: preserves the keyframe's value + per-keyframe ease. Both // percentages are tween-relative (the drag handler converts the drop // position before calling). No optimistic runtime hold — the soft-reload // re-keys the diamond from source. trackStudioEvent("keyframe", { action: "retime" }); - moveKeyframe(sel, animId, fromPercentage, toPercentage); + return moveKeyframe(sel, animId, fromPercentage, toPercentage); }, - [domEditSelection, moveKeyframe], + [resolveWriteSelection, moveKeyframe], ); const handleGsapResizeKeyframedTween = useCallback( @@ -368,14 +399,14 @@ export function useGsapSelectionHandlers({ pctRemap: Array<{ from: number; to: number }>, selectionOverride?: DomEditSelection | null, ) => { - const sel = selectionOverride ?? domEditSelection ?? lastSelectionRef.current; - if (!sel) return; + const sel = resolveWriteSelection(selectionOverride); + if (!sel) return Promise.resolve(false); // Boundary drag-to-retime: grows/shifts the tween window + re-keys keyframes // in place. Distinct telemetry action so resize is separable from in-window move. trackStudioEvent("keyframe", { action: "retime_resize" }); - resizeKeyframedTween(sel, animId, position, duration, pctRemap); + return resizeKeyframedTween(sel, animId, position, duration, pctRemap); }, - [domEditSelection, resizeKeyframedTween], + [resolveWriteSelection, resizeKeyframedTween], ); const handleGsapConvertToKeyframes = useCallback( @@ -384,37 +415,31 @@ export function useGsapSelectionHandlers({ resolvedFromValues?: Record, duration?: number, commitOverrides?: Partial, + selectionOverride?: DomEditSelection | null, ) => { - if (!domEditSelection) return Promise.resolve(); - return convertToKeyframes( - domEditSelection, - animId, - resolvedFromValues, - duration, - commitOverrides, - ).catch((error) => { - trackGsapHandlerFailure( - error, - domEditSelection, - "convert-to-keyframes", - "Convert to keyframes", - ); - }); + const sel = resolveWriteSelection(selectionOverride); + if (!sel) return Promise.resolve(); + return convertToKeyframes(sel, animId, resolvedFromValues, duration, commitOverrides).catch( + (error) => { + trackGsapHandlerFailure(error, sel, "convert-to-keyframes", "Convert to keyframes"); + }, + ); }, - [domEditSelection, convertToKeyframes, trackGsapHandlerFailure], + [resolveWriteSelection, convertToKeyframes, trackGsapHandlerFailure], ); const handleGsapRemoveAllKeyframes = useCallback( - (animId: string) => { - if (!domEditSelection) return; - observeGsapMutation( - removeAllKeyframes(domEditSelection, animId), - domEditSelection, + (animId: string, selectionOverride?: DomEditSelection | null) => { + const selection = resolveWriteSelection(selectionOverride); + if (!selection) return Promise.resolve(false); + return observeGsapMutation( + removeAllKeyframes(selection, animId), + selection, "remove-all-keyframes", "Remove all keyframes", ); }, - [domEditSelection, observeGsapMutation, removeAllKeyframes], + [resolveWriteSelection, observeGsapMutation, removeAllKeyframes], ); const handleResetSelectedElementKeyframes = useCallback((): boolean => { diff --git a/packages/studio/src/hooks/useGsapTweenCache.test.ts b/packages/studio/src/hooks/useGsapTweenCache.test.ts index d9ff5d4a02..0492a1f501 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.test.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.test.ts @@ -90,4 +90,20 @@ describe("resolveSelectorElementIds", () => { expect(resolveSelectorElementIds("#card .label", null)).toEqual(["card"]); expect(resolveSelectorElementIds(".dot", null)).toEqual([]); }); + + // The `[id="…"]` form is what writers emit for a CSS-unsafe id (digit-leading, + // dotted). The old local `#id`-only regex read no id at all for those, so they + // silently dropped out of both DOM-less paths. + it("falls back to a bracketed id when there is no DOM", () => { + expect(resolveSelectorElementIds('[id="01-hook"] .label', null)).toEqual(["01-hook"]); + }); + + it("falls back to a bracketed id when querySelectorAll rejects the selector", () => { + const doc = { + querySelectorAll: () => { + throw new SyntaxError("bad selector"); + }, + } as unknown as Document; + expect(resolveSelectorElementIds('[id="01-hook"]:has(>*)', doc)).toEqual(["01-hook"]); + }); }); diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 226ec4c194..0f89be655c 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -1,54 +1,30 @@ import { useEffect, useMemo, useRef, useState, useCallback } from "react"; -import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser"; -import { isStudioHoldSet } from "@hyperframes/core/gsap-parser"; +import type { GsapAnimation, GsapKeyframesData } from "@hyperframes/core/gsap-parser"; import { usePlayerStore } from "../player/store/playerStore"; import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeBridge"; import { clearKeyframeCacheForElement, - clearKeyframeCacheForFile, + pruneKeyframeCacheToFiles, + writeGsapAnimationsForElement, } from "./gsapKeyframeCacheHelpers"; -import { toAbsoluteTime } from "./gsapShared"; -import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth"; - -function extractIdFromSelector(selector: string): string | null { - const match = selector.match(/^#([\w-]+)/); - return match ? match[1] : null; -} - -/** - * Resolve a tween's target selector to the ids of the element(s) it animates. - * A bare `#id` resolves directly; anything else (a class like `.dot`, a group - * `.a, .b`, or a descendant selector) is matched against the live preview DOM so - * class/selector tweens (e.g. `gsap.from(".dot", {stagger})`) attribute to every - * element they animate — not just one parsed from the string. Falls back to a - * leading `#id` when there's no DOM (so the cache still populates pre-iframe). - */ -// fallow-ignore-next-line complexity -export function resolveSelectorElementIds( - selector: string, - doc: Document | null | undefined, -): string[] { - const bareId = selector.match(/^#([\w-]+)$/); - if (bareId) return [bareId[1]]; - if (!doc) { - const lead = extractIdFromSelector(selector); - return lead ? [lead] : []; - } - const ids = new Set(); - for (const part of selector.split(",")) { - const sel = part.trim(); - if (!sel) continue; - try { - for (const el of Array.from(doc.querySelectorAll(sel))) { - if (el.id) ids.add(el.id); - } - } catch { - const lead = extractIdFromSelector(sel); - if (lead) ids.add(lead); - } - } - return Array.from(ids); -} +import { toAbsoluteTime, toClipPercentage } from "./gsapShared"; +import { + deduplicateKeyframes, + isStaticPositionHold, + synthesizeFlatTweenKeyframes, +} from "./gsapTweenSynth"; +import { + fetchParsedAnimations, + populateKeyframeCacheFromAst, + resolveClipTimingBasis, +} from "./keyframeCacheAstLoad"; + +// Re-exported so callers keep importing the GSAP cache surface from one module. +export { + fetchParsedAnimations, + resolveClipTimingBasis, + resolveSelectorElementIds, +} from "./keyframeCacheAstLoad"; /** The selected element's identity for matching tweens to it. */ export interface GsapElementTarget { @@ -98,59 +74,6 @@ export function getAnimationsForElement( ); } -export async function fetchParsedAnimations( - projectId: string, - sourceFile: string, -): Promise { - try { - const res = await fetch( - `/api/projects/${encodeURIComponent(projectId)}/gsap-animations/${encodeURIComponent(sourceFile)}`, - // Always re-read the freshly-parsed source; no per-call timestamp (which - // would defeat caching forever and is a deterministic-render no-no). - { cache: "no-store" }, - ); - if (!res.ok) return null; - const parsed = (await res.json()) as ParsedGsap; - // Studio-emitted pre-keyframe hold `set`s are an internal runtime detail (they - // hold an element's first keyframe before its tween). They must not surface as - // user animations — otherwise they pollute the keyframe cache / timeline diamonds. - return { ...parsed, animations: parsed.animations.filter((a) => !isStudioHoldSet(a)) }; - } catch { - return null; - } -} - -/** - * Clip-relative timing basis for an element. Sub-composition internals (e.g. pills - * inside a scene) aren't timeline clips themselves — they're derived at expand time - * — so they're absent from `elements`. Without a basis, elDuration defaulted to 1 - * and clip-relative keyframe percentages blew past 100% (rendering off the clip). - * Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's - * data-composition-src is stripped in the rendered DOM, so we can't query it). - */ -export function resolveClipTimingBasis( - elementId: string, - sourceFile: string, - elements: ReadonlyArray<{ - domId?: string; - key?: string; - id: string; - start: number; - duration: number; - }>, - domClipChildren: ReadonlyArray<{ id: string; hostId: string }>, -): { elStart: number; elDuration: number } { - const direct = elements.find( - (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`, - ); - if (direct) return { elStart: direct.start, elDuration: direct.duration }; - const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId; - const host = hostId - ? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`) - : undefined; - return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 }; -} - export function useGsapAnimationsForElement( projectId: string | null, sourceFile: string, @@ -328,6 +251,17 @@ export function useGsapAnimationsForElement( // fallow-ignore-next-line complexity useEffect(() => { if (!elementId) return; + // Same admission rule as the keyframe cache below (hold skip included) and + // no property-group filter: the two stores must agree, or a hold draws an + // expanded property lane with no collapsed diamond behind it and an + // ungrouped tween draws diamonds with no lane source. + const sourceAnimations = animations.filter( + (animation) => + !isStaticPositionHold(animation) && + (animation.keyframes || synthesizeFlatTweenKeyframes(animation)), + ); + if (sourceAnimations.length > 0) + writeGsapAnimationsForElement(sourceFile, elementId, sourceAnimations); // Resolve the element's time range from the player store so we can // convert tween-relative keyframe percentages to clip-relative ones. @@ -340,24 +274,17 @@ export function useGsapAnimationsForElement( ); const allKeyframes: Array< - GsapKeyframesData["keyframes"][0] & { tweenPercentage?: number; propertyGroup?: string } + GsapKeyframesData["keyframes"][0] & { + tweenPercentage?: number; + propertyGroup?: string; + animationId?: string; + } > = []; let format: GsapKeyframesData["format"] = "percentage"; let ease: string | undefined; let easeEach: string | undefined; for (const anim of animations) { - // A static position hold (only x/y, no real motion) is a `set`, not a - // keyframe — don't synthesize a diamond for it. Covers both `tl.set(...)` - // and the `tl.to({ duration: 0, immediateRender: true })` hold that - // remove-all-keyframes collapses to (which is otherwise shown as a stray - // 0% keyframe). - if ( - !anim.keyframes && - Object.keys(anim.properties).length > 0 && - Object.keys(anim.properties).every((k) => k === "x" || k === "y") && - (anim.method === "set" || (anim.duration ?? 0) === 0) - ) - continue; + if (isStaticPositionHold(anim)) continue; const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim); if (!kf) continue; // Convert tween-relative percentages to clip-relative so diamonds @@ -367,17 +294,13 @@ export function useGsapAnimationsForElement( const tweenDur = anim.duration ?? elDuration; for (const k of kf.keyframes) { const absTime = toAbsoluteTime(tweenPos, tweenDur, k.percentage); - // 0.001% precision (was 0.1%) so a beat-snapped keyframe centers exactly - // on the beat dot, which is rendered at the true beat time. - const clipPct = - elDuration > 0 - ? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000 - : k.percentage; + const clipPct = toClipPercentage(absTime, elStart, elDuration, k.percentage); allKeyframes.push({ ...k, percentage: clipPct, tweenPercentage: k.percentage, propertyGroup: anim.propertyGroup, + animationId: anim.id, }); } format = kf.format; @@ -424,6 +347,7 @@ export function useGsapCacheVersion() { * elements. Called from the Timeline component so diamonds show without * requiring a selection. */ + export function usePopulateKeyframeCacheForFile( projectId: string | null, sourceFile: string, @@ -431,6 +355,16 @@ export function usePopulateKeyframeCacheForFile( iframeRef?: React.RefObject, ): void { const elementCount = usePlayerStore((s) => s.elements.length); + // Every sub-composition file the timeline shows rows for. The cache is loaded + // for all of them up front, so keyframe lanes are populated on open instead of + // only once a clip from that file is selected (which is what switches + // `sourceFile`). Only files reachable from the store's elements are covered; + // a composition nested inside another still loads on first selection. + const compositionSrcKey = usePlayerStore((s) => + Array.from(new Set(s.elements.map((el) => el.compositionSrc).filter((src) => !!src))) + .sort() + .join("|"), + ); // Re-run when sub-comp DOM children appear (they supply the host bounds the // clip-relative keyframe percentages are computed against; without this the // cache is computed once before they exist and the percentages stay wrong). @@ -443,72 +377,24 @@ export function usePopulateKeyframeCacheForFile( const astFetchDoneRef = useRef(""); useEffect(() => { - const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}`; + const fetchKey = `kf-cache:${projectId}:${sourceFile}:${version}:${elementCount}:${domClipChildrenKey}:${compositionSrcKey}`; if (fetchKey === lastFetchKeyRef.current) return; lastFetchKeyRef.current = fetchKey; runtimeScanDoneRef.current = ""; astFetchDoneRef.current = ""; if (!projectId) return; - const sf = sourceFile; - // fallow-ignore-next-line complexity - fetchParsedAnimations(projectId, sf).then((parsed) => { - if (!parsed) return; - const { setKeyframeCache } = usePlayerStore.getState(); - clearKeyframeCacheForFile(sf); - const { elements, domClipChildren } = usePlayerStore.getState(); - const doc = iframeRef?.current?.contentDocument; - const mergedByElement = new Map(); - for (const anim of parsed.animations) { - if (anim.hasUnresolvedKeyframes) continue; - // Position-only static holds are not keyframed animations — skip them so - // they don't draw a timeline diamond. Covers both a `tl.set(...)` and the - // `tl.to({ duration: 0, immediateRender: true })` that remove-all-keyframes - // collapses a keyframed tween to. - if (!anim.keyframes && (anim.method === "set" || (anim.duration ?? 0) === 0)) { - const propKeys = Object.keys(anim.properties).filter((k) => k !== "immediateRender"); - if (propKeys.length > 0 && propKeys.every((k) => k === "x" || k === "y")) { - continue; - } - } - const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim); - if (!kfData) continue; - const tweenPos = - anim.resolvedStart ?? (typeof anim.position === "number" ? anim.position : 0); - const tweenDur = anim.duration ?? 1; - // Attribute the tween to every element it animates (handles class / - // group / descendant selectors, not just `#id`). - for (const id of resolveSelectorElementIds(anim.targetSelector, doc)) { - const { elStart, elDuration } = resolveClipTimingBasis(id, sf, elements, domClipChildren); - const clipKeyframes = kfData.keyframes.map((kf) => { - const absTime = toAbsoluteTime(tweenPos, tweenDur, kf.percentage); - // 0.001% precision (matching useGsapAnimationsForElement above) so a - // beat-snapped keyframe centers exactly on the beat dot and the two - // caches agree on a keyframe's percentage. - const clipPct = - elDuration > 0 - ? Math.round(((absTime - elStart) / elDuration) * 100000) / 1000 - : kf.percentage; - return { - ...kf, - percentage: clipPct, - tweenPercentage: kf.percentage, - propertyGroup: anim.propertyGroup, - }; - }); - const existing = mergedByElement.get(id); - if (existing) { - existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]); - } else { - mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes }); - } - } - } - for (const [id, kfData] of mergedByElement) { - setKeyframeCache(`${sf}#${id}`, kfData); - setKeyframeCache(id, kfData); - if (sf !== "index.html") setKeyframeCache(`index.html#${id}`, kfData); - } + // The active file first: it owns the selection, and each file clears only + // its own cache entries, so the order just decides who writes the bare + // `id` alias last. + const files = Array.from( + new Set([sourceFile, ...(compositionSrcKey ? compositionSrcKey.split("|") : [])]), + ); + const doc = iframeRef?.current?.contentDocument; + // Everything the previous scan cached for a file this one no longer covers + // (the composition just switched away from) has no owner left to clear it. + pruneKeyframeCacheToFiles(files); + Promise.all(files.map((sf) => populateKeyframeCacheFromAst(projectId, sf, doc))).then(() => { astFetchDoneRef.current = fetchKey; }); // elementCount is in the deps because new timeline elements (e.g. after a @@ -517,7 +403,7 @@ export function usePopulateKeyframeCacheForFile( // iframeRef is read for DOM selector resolution but intentionally not a dep // (it's a stable ref; the separate runtime-scan effect owns iframe timing). // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectId, sourceFile, version, elementCount, domClipChildrenKey]); + }, [projectId, sourceFile, version, elementCount, domClipChildrenKey, compositionSrcKey]); // Separate effect for runtime keyframe discovery — polls until the iframe // has loaded GSAP timelines, independent of the AST fetch lifecycle. diff --git a/packages/studio/src/hooks/usePopulateKeyframeCacheForFile.test.tsx b/packages/studio/src/hooks/usePopulateKeyframeCacheForFile.test.tsx new file mode 100644 index 0000000000..6d2f609b4f --- /dev/null +++ b/packages/studio/src/hooks/usePopulateKeyframeCacheForFile.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment happy-dom + +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { usePopulateKeyframeCacheForFile } from "./useGsapTweenCache"; +import { usePlayerStore } from "../player/store/playerStore"; + +Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); + +function HookHost() { + usePopulateKeyframeCacheForFile("demo", "index.html", 1); + return null; +} + +let root: Root | null = null; +let container: HTMLElement | null = null; + +beforeEach(() => { + usePlayerStore.setState({ + elements: [ + { + id: "lab", + tag: "div", + start: 0, + duration: 12, + track: 1, + compositionSrc: "compositions/keyframe-lab.html", + }, + ] as never, + }); +}); + +afterEach(() => { + if (root) { + act(() => root?.unmount()); + root = null; + } + container?.remove(); + vi.unstubAllGlobals(); +}); + +describe("usePopulateKeyframeCacheForFile", () => { + it("loads every sub-composition file the timeline shows, not just the active one", async () => { + // Keyframe lanes must be populated when the project opens. Fetching only the + // active file left them empty until a clip from the sub-composition was + // selected (which is the only thing that switched `sourceFile`). + const urls: string[] = []; + const fetchMock = vi.fn((input: RequestInfo | URL) => { + urls.push(String(input)); + return Promise.resolve({ ok: true, json: () => Promise.resolve({ animations: [] }) }); + }); + vi.stubGlobal("fetch", fetchMock); + + act(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + root.render(); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect(urls.some((u) => u.endsWith("/index.html"))).toBe(true); + expect(urls.some((u) => u.includes("keyframe-lab.html"))).toBe(true); + }); +}); 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..08c4c21ed2 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,12 @@ 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. + // Deliberately wider than shouldShowSelectedDomBounds: the on-canvas path + // handles ARE the arc-drag affordance, so gating them on an open Inspector + // would make keyframe path editing reachable only from a side panel. + 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 +109,14 @@ export function useInspectorState( !isPlaying && !isGestureRecording, }; - }, [rightPanelTab, rightInspectorPanes, rightCollapsed, isPlaying, isGestureRecording]); + }, [ + rightPanelTab, + rightInspectorPanes, + rightCollapsed, + isPlaying, + isGestureRecording, + domEditSelection, + ]); } // fallow-ignore-next-line complexity diff --git a/packages/studio/src/hooks/useStudioTestHooks.ts b/packages/studio/src/hooks/useStudioTestHooks.ts new file mode 100644 index 0000000000..a434677c4a --- /dev/null +++ b/packages/studio/src/hooks/useStudioTestHooks.ts @@ -0,0 +1,63 @@ +import { useEffect } from "react"; +import type { DomEditSelection } from "../components/editor/domEditing"; + +interface StudioTestHookDeps { + previewIframeRef: React.MutableRefObject; + buildDomSelectionFromTarget: (target: HTMLElement) => Promise; + applyDomSelection: ( + selection: DomEditSelection | null, + options?: { revealPanel?: boolean }, + ) => void; +} + +interface StudioTestApi { + selectByDomId: (id: string) => Promise; +} + +declare global { + interface Window { + __studioTest?: StudioTestApi; + } +} + +/** + * Dev-only headless-QA shortcut. Selecting an element normally requires a + * pixel-precise click inside the preview iframe, which automated verification + * can't reliably land. `window.__studioTest.selectByDomId(id)` resolves the + * DomEditSelection for a preview element by id and reveals the inspector — + * exactly what a click does — so a driver can open the property/ease panels and + * then focus a segment via `__playerStore.getState().setFocusedEaseSegment`. + * No-op in production builds. + */ +export function useStudioTestHooks({ + previewIframeRef, + buildDomSelectionFromTarget, + applyDomSelection, +}: StudioTestHookDeps): void { + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + let isDev = false; + try { + isDev = import.meta.env.DEV === true; + } catch { + isDev = false; + } + if (!isDev || typeof window === "undefined") return; + const api: StudioTestApi = { + selectByDomId: async (id: string): Promise => { + const element = previewIframeRef.current?.contentDocument?.getElementById(id) ?? null; + if (!element) return false; + const selection = await buildDomSelectionFromTarget(element); + if (!selection) return false; + applyDomSelection(selection, { revealPanel: true }); + return true; + }, + }; + window.__studioTest = api; + return () => { + // delete, not `= undefined`: an own key holding undefined keeps + // `"__studioTest" in window` true, which defeats feature detection. + delete window.__studioTest; + }; + }, [applyDomSelection, buildDomSelectionFromTarget, previewIframeRef]); +} diff --git a/packages/studio/src/hooks/useTimelineEditing.test.tsx b/packages/studio/src/hooks/useTimelineEditing.test.tsx index bc88aca67d..18d81aa55d 100644 --- a/packages/studio/src/hooks/useTimelineEditing.test.tsx +++ b/packages/studio/src/hooks/useTimelineEditing.test.tsx @@ -10,6 +10,17 @@ import { jsonResponse, requestUrl } from "./fetchStubTestUtils"; import { useElementLifecycleOps } from "./useElementLifecycleOps"; import { useTimelineEditing } from "./useTimelineEditing"; +vi.mock("../components/editor/manualEditingAvailability", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + STUDIO_SDK_CUTOVER_ENABLED: true, + STUDIO_SDK_CUTOVER_FAMILIES: new Set(["timing"]), + STUDIO_SDK_RESOLVER_SHADOW_ENABLED: false, + }; +}); + (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; type ZIndexEntry = { @@ -108,7 +119,9 @@ function renderTimelineEditingHook(input: { }) => Promise; reloadPreview?: () => void; sdkSession?: Awaited> | null; + publishSdkSession?: NonNullable[0]["publishSdkSession"]>; forceReloadSdkSession?: () => void; + invalidateGsapCache?: () => void; showToast?: (message: string, kind?: string) => void; }): { move: ReturnType["handleTimelineElementMove"]; @@ -140,7 +153,9 @@ function renderTimelineEditingHook(input: { pendingTimelineEditPathRef: { current: new Set() }, uploadProjectFiles: vi.fn(), sdkSession: input.sdkSession, + publishSdkSession: input.publishSdkSession, forceReloadSdkSession: input.forceReloadSdkSession, + invalidateGsapCache: input.invalidateGsapCache, handleDomZIndexReorderCommitRef: commitRef, }); move = hook.handleTimelineElementMove; @@ -163,6 +178,9 @@ function renderTimelineEditingHook(input: { type TimelineRecordEdit = NonNullable< Parameters[0]["recordEdit"] >; +type TimelinePublishSdkSession = NonNullable< + Parameters[0]["publishSdkSession"] +>; function renderTimelineEditingHookWithLifecycle(input: { timelineElements: TimelineElement[]; @@ -227,28 +245,41 @@ async function flushAsyncWork(): Promise { * with `gsapBody`. Returns the mock for call inspection. */ function stubProjectFetch(files: string | Record, gsapBody?: unknown) { - // Keep this test server's capability, file-read, and mutation routes together; - // splitting the fixture would obscure the request sequence asserted by callers. - // fallow-ignore-next-line complexity - const fetchMock = vi.fn(async (input: Parameters[0]): Promise => { - const url = requestUrl(input); - if (url.includes("/api/projects/p1/gsap-mutation-capabilities")) { - return jsonResponse({ atomicOwnershipPairs: true }); - } - if (url.includes("/api/projects/p1/files/")) { - if (typeof files === "string") return jsonResponse({ content: files }); - const path = decodeURIComponent(url.split("/files/")[1] ?? "index.html"); - return jsonResponse({ content: files[path] }); - } - if (url.includes("/api/projects/p1/gsap-mutations/")) { - const path = decodeURIComponent(url.split("/gsap-mutations/")[1] ?? "index.html"); - const content = typeof files === "string" ? files : (files[path] ?? ""); - return jsonResponse( - gsapBody ?? { mutated: false, scriptText: null, before: content, after: content }, - ); - } - throw new Error(`Unexpected fetch: ${url}`); - }); + const pathAfter = (url: string, marker: string) => + decodeURIComponent(url.split(marker)[1] ?? "index.html"); + const fileContent = (path: string) => (typeof files === "string" ? files : files[path]); + // One handler per route, so the mock itself stays a lookup: the request + // sequence callers assert on is still readable top to bottom. + const routes: Array<[marker: string, respond: (url: string) => Response]> = [ + [ + "/api/projects/p1/gsap-mutation-capabilities", + () => jsonResponse({ atomicOwnershipPairs: true }), + ], + [ + "/api/projects/p1/files/", + (url) => jsonResponse({ content: fileContent(pathAfter(url, "/files/")) }), + ], + [ + "/api/projects/p1/gsap-mutations/", + (url) => { + const content = fileContent(pathAfter(url, "/gsap-mutations/")) ?? ""; + return jsonResponse( + gsapBody ?? { mutated: false, scriptText: null, before: content, after: content }, + ); + }, + ], + ]; + const fetchMock = vi.fn( + async ( + input: Parameters[0], + _init?: Parameters[1], + ): Promise => { + const url = requestUrl(input); + const route = routes.find(([marker]) => url.includes(marker)); + if (!route) throw new Error(`Unexpected fetch: ${url}`); + return route[1](url); + }, + ); vi.stubGlobal("fetch", fetchMock); return fetchMock; } @@ -285,6 +316,39 @@ function setupSingleClipHarness(options?: { return { iframe, clip, commit, writeProjectFile, reloadPreview, fetchMock, ...hook }; } +const SDK_KEYFRAMED_SOURCE = [ + `
`, + `
`, + `
`, + ``, +].join("\n"); + +async function setupSdkKeyframedClipHarness() { + const iframe = createPreviewIframe([{ id: "clip", track: 0 }]); + const clip = timelineElement({ id: "clip", track: 0, zIndex: 0, start: 1 }); + const sdkSession = await openComposition(SDK_KEYFRAMED_SOURCE); + const writeProjectFile = vi.fn<(...args: unknown[]) => Promise>(async () => {}); + const invalidateGsapCache = vi.fn(); + const fetchMock = stubProjectFetch(SDK_KEYFRAMED_SOURCE); + usePlayerStore.getState().setDuration(10); + const hook = renderTimelineEditingHook({ + timelineElements: [clip], + iframe, + onZIndexCommit: vi.fn().mockResolvedValue(undefined), + projectId: "p1", + writeProjectFile, + recordEdit: vi.fn(async () => {}), + sdkSession, + publishSdkSession: vi.fn(() => "published"), + invalidateGsapCache, + }); + return { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile }; +} + /** Assert a lane write landed in both the live iframe DOM and the persisted file. */ function expectLanePersisted( iframe: HTMLIFrameElement, @@ -710,6 +774,58 @@ describe("useTimelineEditing timeline z-index reorder", () => { h.unmount(); }); + it("shifts authored GSAP positions after an SDK-backed clip move commits", async () => { + const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } = + await setupSdkKeyframedClipHarness(); + + await act(async () => { + await hook.move(clip, { start: 2.25, track: clip.track }); + }); + + expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-start="2.25"'); + const mutationCall = fetchMock.mock.calls.find((call) => + requestUrl(call[0]).includes("/gsap-mutations/"), + ); + expect(mutationCall).toBeDefined(); + const init = mutationCall?.[1] as RequestInit | undefined; + expect(JSON.parse(String(init?.body))).toEqual({ + type: "shift-positions", + targetSelector: "#clip", + delta: 1.25, + }); + expect(invalidateGsapCache).toHaveBeenCalledTimes(1); + + hook.unmount(); + }); + + it("scales authored GSAP positions after an SDK-backed clip resize commits", async () => { + const { clip, fetchMock, hook, invalidateGsapCache, writeProjectFile } = + await setupSdkKeyframedClipHarness(); + + await act(async () => { + await hook.resize(clip, { start: 2, duration: 4, playbackStart: undefined }); + }); + + expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-start="2"'); + expect(writeProjectFile.mock.calls[0]?.[1]).toContain('data-duration="4"'); + const mutationCall = fetchMock.mock.calls.find((call) => + requestUrl(call[0]).includes("/gsap-mutations/"), + ); + expect(mutationCall).toBeDefined(); + const init = mutationCall?.[1] as RequestInit | undefined; + expect(JSON.parse(String(init?.body))).toEqual({ + type: "scale-positions", + targetSelector: "#clip", + oldStart: 1, + oldDuration: 2, + newStart: 2, + newDuration: 4, + }); + expect(invalidateGsapCache).toHaveBeenCalledTimes(1); + + hook.unmount(); + }); + it("persists a vertical-only lane move (start unchanged) through the single-element fallback", async () => { // Regression: `if (!startChanged) return` ran BEFORE the file persist, so a // pure lane change routed through onMoveElement (no onMoveElements wired) @@ -821,6 +937,55 @@ describe("useTimelineEditing timeline z-index reorder", () => { unmount(); }); + it("shifts every keyed clip and invalidates the cache after an SDK-backed group move", async () => { + const source = [ + `
`, + `
`, + `
`, + `
`, + ``, + ].join("\n"); + const { iframe, a, b } = makeTwoClipPair(); + const sdkSession = await openComposition(source); + const fetchMock = stubProjectFetch(source); + const invalidateGsapCache = vi.fn(); + usePlayerStore.getState().setDuration(10); + const hook = renderTimelineEditingHook({ + timelineElements: [a, b], + iframe, + onZIndexCommit: vi.fn().mockResolvedValue(undefined), + projectId: "p1", + writeProjectFile: vi.fn<(...args: unknown[]) => Promise>(async () => {}), + recordEdit: vi.fn(async () => {}), + sdkSession, + publishSdkSession: vi.fn(() => "published"), + invalidateGsapCache, + }); + + await act(async () => { + await hook.groupMove([ + { element: a, start: 1 }, + { element: b, start: 2 }, + ]); + }); + + const mutations = fetchMock.mock.calls + .filter((call) => requestUrl(call[0]).includes("/gsap-mutations/")) + .map((call) => JSON.parse(String((call[1] as RequestInit | undefined)?.body))); + expect(mutations).toEqual([ + { type: "shift-positions", targetSelector: "#a", delta: 1 }, + { type: "shift-positions", targetSelector: "#b", delta: 1 }, + ]); + expect(invalidateGsapCache).toHaveBeenCalledTimes(1); + + hook.unmount(); + }); + it("partitions a group move by source file while keeping one undo entry", async () => { const files: Record = { "index.html": '
', diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 2c53299a20..bf846f06fe 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -58,6 +58,7 @@ export function useTimelineEditing({ sdkSession, publishSdkSession, forceReloadSdkSession, + invalidateGsapCache, handleDomZIndexReorderCommitRef, }: UseTimelineEditingOptions) { const projectIdRef = useRef(projectId); @@ -118,6 +119,7 @@ export function useTimelineEditing({ domEditSaveTimestampRef, editQueueRef, forceReloadSdkSession, + invalidateGsapCache, isRecordingRef, pendingTimelineEditPathRef, previewIframeRef, @@ -184,21 +186,24 @@ export function useTimelineEditing({ ); }; const coalesceKey = `timeline-move:${element.hfId ?? element.id}`; + const finishMoveGsapSync = () => + // Every timing writer converges the same GSAP positions after its + // durable clip-start commit. The SDK owns the attribute write; this + // sync owns only the dependent animation rewrite and preview refresh. + finishClipTimingFallback({ + iframe: previewIframeRef.current, + reloadPreview, + projectId: projectIdRef.current, + targetPath, + domId: element.domId, + label: "Move timeline clip", + coalesceKey, + recordEdit, + edit: { kind: "shift", delta: updates.start - element.start }, + }).finally(() => invalidateGsapCache?.()); const moveFallback = () => - enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then(() => - // Soft-reload with the server's rewritten GSAP script — the timing-only move already patched - // DOM + store, so swapping the script avoids the all-clips flash; falls back to reloadPreview(). - finishClipTimingFallback({ - iframe: previewIframeRef.current, - reloadPreview, - projectId: projectIdRef.current, - targetPath, - domId: element.domId, - label: "Move timeline clip", - coalesceKey, - recordEdit, - edit: { kind: "shift", delta: updates.start - element.start }, - }), + enqueueEdit(element, "Move timeline clip", buildMovePatches, coalesceKey).then( + finishMoveGsapSync, ); return reorderDone .then(() => { @@ -221,9 +226,10 @@ export function useTimelineEditing({ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), publishSession: publishSdkSession, }, - { label: "Move timeline clip", coalesceKey }, + { label: "Move timeline clip", coalesceKey, skipRefresh: true }, ).then((result) => { if (!cutoverCommittedOrThrow(result)) return moveFallback(); + return finishMoveGsapSync(); }); } return moveFallback(); @@ -250,6 +256,7 @@ export function useTimelineEditing({ timelineElements, handleDomZIndexReorderCommitRef, showToast, + invalidateGsapCache, ], ); @@ -287,23 +294,25 @@ export function useTimelineEditing({ // script (timing-only resize) — same no-flash path as move; full reload is // the fallback. const coalesceKey = `timeline-resize:${element.hfId ?? element.id}`; + const finishResizeGsapSync = () => + finishClipTimingFallback({ + iframe: previewIframeRef.current, + reloadPreview, + projectId: projectIdRef.current, + targetPath, + domId: element.domId, + label: "Resize timeline clip", + coalesceKey, + recordEdit, + edit: { + kind: "scale", + from: { start: element.start, duration: element.duration }, + to: { start: updates.start, duration: updates.duration }, + }, + }).finally(() => invalidateGsapCache?.()); const resizeFallback = () => - enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then(() => - finishClipTimingFallback({ - iframe: previewIframeRef.current, - reloadPreview, - projectId: projectIdRef.current, - targetPath, - domId: element.domId, - label: "Resize timeline clip", - coalesceKey, - recordEdit, - edit: { - kind: "scale", - from: { start: element.start, duration: element.duration }, - to: { start: updates.start, duration: updates.duration }, - }, - }), + enqueueEdit(element, "Resize timeline clip", buildResizePatches, coalesceKey).then( + finishResizeGsapSync, ); const persistDone = sdkSession && element.hfId && !hasPbsAdjustment && !needsExtension @@ -323,9 +332,10 @@ export function useTimelineEditing({ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), publishSession: publishSdkSession, }, - { label: "Resize timeline clip", coalesceKey }, + { label: "Resize timeline clip", coalesceKey, skipRefresh: true }, ).then((result) => { if (!cutoverCommittedOrThrow(result)) return resizeFallback(); + return finishResizeGsapSync(); }) : resizeFallback(); return persistDone.catch((error) => { @@ -346,6 +356,7 @@ export function useTimelineEditing({ reloadPreview, domEditSaveTimestampRef, showToast, + invalidateGsapCache, ], ); diff --git a/packages/studio/src/hooks/useTimelineEditingTypes.ts b/packages/studio/src/hooks/useTimelineEditingTypes.ts index a391177064..bd3df209d8 100644 --- a/packages/studio/src/hooks/useTimelineEditingTypes.ts +++ b/packages/studio/src/hooks/useTimelineEditingTypes.ts @@ -46,6 +46,8 @@ export interface UseTimelineEditingOptions { publishSdkSession?: PublishSdkSession; /** Resync the SDK session after a server-authoritative timeline write. */ forceReloadSdkSession?: () => void; + /** Reparse authored animations after a timing rewrite changes their positions. */ + invalidateGsapCache?: () => void; handleDomZIndexReorderCommitRef?: MutableRefObject; } diff --git a/packages/studio/src/hooks/useTimelineGroupEditing.ts b/packages/studio/src/hooks/useTimelineGroupEditing.ts index 8a3fe83a81..03abbb6930 100644 --- a/packages/studio/src/hooks/useTimelineGroupEditing.ts +++ b/packages/studio/src/hooks/useTimelineGroupEditing.ts @@ -54,6 +54,7 @@ interface UseTimelineGroupEditingOptions { domEditSaveTimestampRef: MutableRefObject; editQueueRef: MutableRefObject>; forceReloadSdkSession?: () => void; + invalidateGsapCache?: () => void; isRecordingRef?: RefObject; pendingTimelineEditPathRef: MutableRefObject>; previewIframeRef: RefObject; @@ -110,6 +111,7 @@ export function useTimelineGroupEditing({ domEditSaveTimestampRef, editQueueRef, forceReloadSdkSession, + invalidateGsapCache, isRecordingRef, pendingTimelineEditPathRef, previewIframeRef, @@ -212,7 +214,12 @@ export function useTimelineGroupEditing({ readProjectFile: (path) => readFileContent(projectIdRef.current ?? "", path), publishSession: publishSdkSession, }, - { label: input.label, coalesceKey: input.coalesceKey, coalesceMs: input.coalesceMs }, + { + label: input.label, + coalesceKey: input.coalesceKey, + coalesceMs: input.coalesceMs, + skipRefresh: true, + }, ); return cutoverCommittedOrThrow(result); }, @@ -282,47 +289,55 @@ export function useTimelineGroupEditing({ coalesceKey, coalesceMs, }); - if (handledBySdk) return; - - await persistServerBatch( - projectId, - "Move timeline clips", - changes.map((change) => ({ - element: change.element, - buildPatches: (original, target) => - buildTimelineMoveTimingPatch( - original, - target, - change.start, - change.element.duration, - change.track, - ), - })), - coalesceKey, - coalesceMs, - ); + if (!handledBySdk) { + await persistServerBatch( + projectId, + "Move timeline clips", + changes.map((change) => ({ + element: change.element, + buildPatches: (original, target) => + buildTimelineMoveTimingPatch( + original, + target, + change.start, + change.element.duration, + change.track, + ), + })), + coalesceKey, + coalesceMs, + ); + } // Track-only: no timing delta → no GSAP positions to shift and no // reload (see the trackOnly doc above). Mixed batches (any start // change) keep the full fallback below. if (trackOnly) return; - await finishGroupTimingGsapFallback({ - projectId, - iframe: previewIframeRef.current, - reloadPreview, - label: "Move timeline clips", - errorLabel: "Failed to shift GSAP positions", - coalesceKey, - recordEdit, - activeCompPath, - changes, - resolveChangePath: (element) => targetPathFor(element, activeCompPath), - mutateChange: (change, changePath) => { - const delta = change.start - change.element.start; - const domId = change.element.domId; - if (delta === 0 || !domId) return null; - return shiftGsapPositions(projectId, changePath, domId, delta); - }, - }); + // The timing persist above already committed to disk, so the cached + // GSAP read is stale whether or not the position rewrite succeeded — + // invalidate on the error path too (matches the single-element path's + // `.finally`), or a failed rewrite leaves the editor reading old tweens. + try { + await finishGroupTimingGsapFallback({ + projectId, + iframe: previewIframeRef.current, + reloadPreview, + label: "Move timeline clips", + errorLabel: "Failed to shift GSAP positions", + coalesceKey, + recordEdit, + activeCompPath, + changes, + resolveChangePath: (element) => targetPathFor(element, activeCompPath), + mutateChange: (change, changePath) => { + const delta = change.start - change.element.start; + const domId = change.element.domId; + if (delta === 0 || !domId) return null; + return shiftGsapPositions(projectId, changePath, domId, delta); + }, + }); + } finally { + invalidateGsapCache?.(); + } }).catch((error) => { // Failed persist: revert the optimistic duration readout + live root // alongside the gesture owner's store rollback. @@ -340,6 +355,7 @@ export function useTimelineGroupEditing({ reloadPreview, trySdkBatchPersist, showToast, + invalidateGsapCache, ], ); @@ -384,50 +400,57 @@ export function useTimelineGroupEditing({ coalesceKey, coalesceMs, }); - if (handledBySdk) return; - - await persistServerBatch( - projectId, - "Resize timeline clips", - changes.map((change) => ({ - element: change.element, - buildPatches: (original, target) => - buildTimelineResizeTimingPatch(original, target, change.element, { - start: change.start, - duration: change.duration, - playbackStart: change.playbackStart, - }), - })), - coalesceKey, - coalesceMs, - ); - await finishGroupTimingGsapFallback({ - projectId, - iframe: previewIframeRef.current, - reloadPreview, - label: "Resize timeline clips", - errorLabel: "Failed to scale GSAP positions", - coalesceKey, - recordEdit, - activeCompPath, - changes, - resolveChangePath: (element) => targetPathFor(element, activeCompPath), - mutateChange: (change, changePath) => { - const domId = change.element.domId; - const timingChanged = - change.start !== change.element.start || change.duration !== change.element.duration; - if (!timingChanged || !domId) return null; - return scaleGsapPositions( - projectId, - changePath, - domId, - change.element.start, - change.element.duration, - change.start, - change.duration, - ); - }, - }); + if (!handledBySdk) { + await persistServerBatch( + projectId, + "Resize timeline clips", + changes.map((change) => ({ + element: change.element, + buildPatches: (original, target) => + buildTimelineResizeTimingPatch(original, target, change.element, { + start: change.start, + duration: change.duration, + playbackStart: change.playbackStart, + }), + })), + coalesceKey, + coalesceMs, + ); + } + // See the move path: the timing persist is already on disk, so the GSAP + // cache must be invalidated even when the position rewrite throws. + try { + await finishGroupTimingGsapFallback({ + projectId, + iframe: previewIframeRef.current, + reloadPreview, + label: "Resize timeline clips", + errorLabel: "Failed to scale GSAP positions", + coalesceKey, + recordEdit, + activeCompPath, + changes, + resolveChangePath: (element) => targetPathFor(element, activeCompPath), + mutateChange: (change, changePath) => { + const domId = change.element.domId; + const timingChanged = + change.start !== change.element.start || + change.duration !== change.element.duration; + if (!timingChanged || !domId) return null; + return scaleGsapPositions( + projectId, + changePath, + domId, + change.element.start, + change.element.duration, + change.start, + change.duration, + ); + }, + }); + } finally { + invalidateGsapCache?.(); + } }).catch((error) => { // Failed persist: revert the optimistic duration readout + live root // alongside the gesture owner's store rollback. @@ -445,6 +468,7 @@ export function useTimelineGroupEditing({ reloadPreview, trySdkBatchPersist, showToast, + invalidateGsapCache, ], ); diff --git a/packages/studio/src/player/components/KeyframeDiamondContextMenu.test.tsx b/packages/studio/src/player/components/KeyframeDiamondContextMenu.test.tsx new file mode 100644 index 0000000000..f802da680e --- /dev/null +++ b/packages/studio/src/player/components/KeyframeDiamondContextMenu.test.tsx @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vitest"; +import type { TimelineElement } from "../store/playerStore"; +import { + KeyframeDiamondContextMenu, + type KeyframeDiamondContextMenuState, +} from "./KeyframeDiamondContextMenu"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const element = { id: "box", start: 0, duration: 2, track: 0 } as unknown as TimelineElement; + +const state: KeyframeDiamondContextMenuState = { + x: 10, + y: 10, + element, + elementId: "box", + percentage: 50, + tweenPercentage: 25, + propertyGroup: "position", + animationId: "box-to-1-position", +}; + +function clickMenuItem(label: string, props: Partial>) { + const host = document.createElement("div"); + document.body.appendChild(host); + const root = createRoot(host); + act(() => + root.render( + {}} + onDelete={vi.fn()} + onDeleteAll={vi.fn()} + {...props} + />, + ), + ); + const button = Array.from(document.body.querySelectorAll("button")).find( + (candidate) => candidate.textContent === label, + ); + act(() => button?.click()); + act(() => root.unmount()); + host.remove(); +} + +describe("KeyframeDiamondContextMenu", () => { + // Two animations can carry a keyframe at the same clip percentage. Dropping the + // property group / tween percentage / animation id here sends the mutation back + // to first-match-by-percentage, which retimes or deletes the wrong tween. + it("hands every action the full keyframe identity, not just the percentage", () => { + const onDelete = vi.fn(); + const onMoveToPlayhead = vi.fn(); + + clickMenuItem("Delete Keyframe", { onDelete }); + clickMenuItem("Move to Playhead", { onMoveToPlayhead }); + + const target = { + percentage: 50, + tweenPercentage: 25, + propertyGroup: "position", + animationId: "box-to-1-position", + }; + expect(onDelete).toHaveBeenCalledWith("box", target); + expect(onMoveToPlayhead).toHaveBeenCalledWith(element, target); + }); +}); diff --git a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx index 34a1cc41ff..be500d494e 100644 --- a/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx +++ b/packages/studio/src/player/components/KeyframeDiamondContextMenu.tsx @@ -1,25 +1,28 @@ import { memo } from "react"; import { createPortal } from "react-dom"; import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss"; +import type { TimelineElement } from "../store/playerStore"; +import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity"; export interface KeyframeDiamondContextMenuState { x: number; y: number; + element: TimelineElement; elementId: string; percentage: number; tweenPercentage?: number; + propertyGroup?: string; + animationId?: string; currentEase?: string; } interface KeyframeDiamondContextMenuProps { state: KeyframeDiamondContextMenuState; onClose: () => void; - onDelete: (elementId: string, percentage: number) => void; - onDeleteAll: (elementId: string) => void; - onChangeEase?: (elementId: string, percentage: number, ease: string) => void; - onCopyProperties?: (elementId: string, percentage: number) => void; + onDelete: (elementId: string, keyframe: TimelineKeyframeTarget) => void; + onDeleteAll: (element: TimelineElement) => void; /** Retime the keyframe to the current playhead, preserving its value + ease. */ - onMoveToPlayhead?: (elementId: string, fromPercentage: number) => void; + onMoveToPlayhead?: (element: TimelineElement, keyframe: TimelineKeyframeTarget) => void; } export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMenu({ @@ -30,6 +33,14 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe onMoveToPlayhead, }: KeyframeDiamondContextMenuProps) { const menuRef = useContextMenuDismiss(onClose); + // The clicked diamond's identity, built once: the menu's two mutating entries + // both act on it, and they must not disagree about which keyframe was clicked. + const keyframe: TimelineKeyframeTarget = { + percentage: state.percentage, + tweenPercentage: state.tweenPercentage, + propertyGroup: state.propertyGroup, + animationId: state.animationId, + }; const menuWidth = 200; const menuHeight = onMoveToPlayhead ? 100 : 70; @@ -51,7 +62,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe // Pass clip-% — resolveKeyframeTarget keys the cache lookup on clip-% // and returns the tween-% for the mutation. Passing tween-% here would // miss the lookup on any tween whose window is shorter than the clip. - onMoveToPlayhead(state.elementId, state.percentage); + onMoveToPlayhead(state.element, keyframe); onClose(); }} > @@ -64,7 +75,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe type="button" className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left" onClick={() => { - onDelete(state.elementId, state.percentage); + onDelete(state.elementId, keyframe); onClose(); }} > @@ -75,7 +86,7 @@ export const KeyframeDiamondContextMenu = memo(function KeyframeDiamondContextMe type="button" className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left" onClick={() => { - onDeleteAll(state.elementId); + onDeleteAll(state.element); onClose(); }} > diff --git a/packages/studio/src/player/components/LayerDisclosureRow.tsx b/packages/studio/src/player/components/LayerDisclosureRow.tsx new file mode 100644 index 0000000000..2f56f0aa82 --- /dev/null +++ b/packages/studio/src/player/components/LayerDisclosureRow.tsx @@ -0,0 +1,76 @@ +import { CaretRight } from "@phosphor-icons/react"; +import type { TimelineElement } from "../store/playerStore"; +import { TRACK_H } from "./timelineLayout"; +import { TrackClipCount } from "./TrackClipCount"; + +// Layer row (Figma order: disclosure ▸/▾, diamond, name) — the disclosure lives +// here, not on the clip bar, and re-expands a collapsed layer. +export function LayerDisclosureRow({ + keyframeClip, + clipCount, + isExpanded, + gutterBackground, + columnWidth, + lanesId, + onToggleClipExpanded, + children, +}: { + keyframeClip: TimelineElement; + clipCount: number; + isExpanded: boolean; + gutterBackground: string; + /** Same adaptive width the lane rows use: a narrowed header column must not + * leave this row hanging over the clips it labels. */ + columnWidth: number; + /** Id of the element holding the lanes this row's caret expands. */ + lanesId: string; + onToggleClipExpanded: () => void; + /** Trailing controls that act on the LAYER (the visibility eye), not on a lane. */ + children?: React.ReactNode; +}) { + const name = keyframeClip.label ?? keyframeClip.domId ?? keyframeClip.id; + return ( +
+ + {/* Decorative: the disclosure button above already names the row's keyframe + state, and aria-label on a plain span is not exposed reliably anyway. */} + + + {name} + + + {children} +
+ ); +} diff --git a/packages/studio/src/player/components/Timeline.test.ts b/packages/studio/src/player/components/Timeline.test.ts index e3db29f4a3..81b095961e 100644 --- a/packages/studio/src/player/components/Timeline.test.ts +++ b/packages/studio/src/player/components/Timeline.test.ts @@ -19,8 +19,11 @@ import { shouldAutoScrollTimeline, } from "./Timeline"; import { + CLIP_Y, FIT_ZOOM_HEADROOM, GUTTER, + LABEL_COL_W, + LANE_H, MIN_TIMELINE_EXTENT_S, PLAYHEAD_HEAD_W, RULER_H, @@ -28,6 +31,7 @@ import { TRACKS_LEFT_PAD, getTimelineDisplayContentWidth, getTimelineFitPps, + getTimelineLaneTop, } from "./timelineLayout"; import { formatTime } from "../lib/time"; import { usePlayerStore } from "../store/playerStore"; @@ -40,52 +44,216 @@ afterEach(() => { usePlayerStore.getState().reset(); }); -describe("Timeline provider boundary", () => { - // fallow-ignore-next-line code-duplication - it("renders the public Timeline export without TimelineEditProvider", () => { - const host = document.createElement("div"); - document.body.append(host); - Object.defineProperty(host, "clientWidth", { - configurable: true, - value: 640, - }); +function getHorizontalGeometry(host: HTMLElement, clipId: string, tickLabel: string) { + const clip = host.querySelector(`[data-el-id="${clipId}"]`); + if (!clip) throw new Error(`Missing timeline clip ${clipId}`); + const trackContent = clip.parentElement; + if (!trackContent) throw new Error(`Missing content row for ${clipId}`); + const trackHeader = trackContent.previousElementSibling; + if (!(trackHeader instanceof HTMLElement)) throw new Error(`Missing track header for ${clipId}`); + const rulerTickLabel = Array.from(host.querySelectorAll("span")).find( + (node) => node.textContent === tickLabel, + ); + const rulerTick = rulerTickLabel?.parentElement; + if (!rulerTick) throw new Error(`Missing ruler tick ${tickLabel}`); + const ruler = rulerTick.parentElement; + if (!ruler) throw new Error("Missing timeline ruler"); + const rulerOrigin = ruler.previousElementSibling; + if (!(rulerOrigin instanceof HTMLElement)) throw new Error("Missing timeline ruler origin"); + const playhead = Array.from(host.querySelectorAll("div")).find( + (node) => node.style.zIndex === "100", + ); + if (!playhead) throw new Error("Missing timeline playhead"); + return { clip, trackHeader, rulerTick, rulerOrigin, playhead }; +} + +function renderTimelineGeometry(clipId: string) { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render(React.createElement(Timeline)); + }); + return { host, root, ...getHorizontalGeometry(host, clipId, "00:10") }; +} + +function createSizedTimelineHost(width: number): HTMLDivElement { + const host = document.createElement("div"); + document.body.append(host); + Object.defineProperty(host, "clientWidth", { configurable: true, value: width }); + return host; +} + +function expectTrackExpansion( + row: HTMLElement | null | undefined, + expandedClipIds: string[], + height: number, +) { + expect(usePlayerStore.getState().expandedClipIds).toEqual(new Set(expandedClipIds)); + expect(row?.style.height).toBe(`${height}px`); +} + +function renderBasicTimeline() { + const host = createSizedTimelineHost(640); + usePlayerStore.setState({ + duration: 4, + timelineReady: true, + elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }], + }); + const root = createRoot(host); + act(() => { + root.render(React.createElement(Timeline)); + }); + return { host, root }; +} +describe("Timeline provider boundary", () => { + it("keeps all-collapsed horizontal positions at the gutter plus the pre-t=0 pad", () => { usePlayerStore.setState({ - duration: 4, + duration: 11, timelineReady: true, - elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }], + currentTime: 10, + zoomMode: "manual", + manualZoomPercent: 100, + elements: [{ id: "clip-1", tag: "div", start: 10, duration: 1, track: 0 }], }); - const root = createRoot(host); - - expect(() => { - act(() => { - root.render(React.createElement(Timeline)); - }); - }).not.toThrow(); + const { root, clip, trackHeader, rulerTick, rulerOrigin, playhead } = + renderTimelineGeometry("clip-1"); + + expect(trackHeader.style.width).toBe(`${GUTTER + TRACKS_LEFT_PAD}px`); + expect(clip.style.left).toBe("1000px"); + expect(clip.style.height).toBe(""); + expect(clip.style.bottom).toBe(`${CLIP_Y}px`); + expect(rulerOrigin.style.width).toBe(`${GUTTER + TRACKS_LEFT_PAD}px`); + expect(rulerTick.style.left).toBe("999.5px"); + expect(playhead.style.left).toBe(`${GUTTER + TRACKS_LEFT_PAD + 1000 - PLAYHEAD_HEAD_W / 2}px`); + expect(playhead.style.width).toBe(`${PLAYHEAD_HEAD_W}px`); + expect( + resolveTimelineAssetDrop( + { + rectLeft: 100, + rectTop: 0, + scrollLeft: 0, + scrollTop: 0, + contentOrigin: GUTTER, + pixelsPerSecond: 100, + duration: 60, + trackOrder: [0], + }, + 1132, + 100, + ).start, + ).toBe(10); + expect(getTimelineFitPps(640, 11, GUTTER)).toBe(10.1); act(() => root.unmount()); }); - // fallow-ignore-next-line code-duplication - it("renders the gutter without legacy icons or hue dots", () => { - const host = document.createElement("div"); - document.body.append(host); - Object.defineProperty(host, "clientWidth", { - configurable: true, - value: 640, - }); - + it("reserves the label column and keeps expanded keyframes aligned with ruler time", () => { usePlayerStore.setState({ - duration: 4, + duration: 20, timelineReady: true, - elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }], + currentTime: 10, + zoomMode: "manual", + manualZoomPercent: 100, + selectedElementId: "clip-1", + expandedClipIds: new Set(["clip-1"]), + elements: [ + { id: "clip-1", label: "Hero card", tag: "div", start: 0, duration: 20, track: 0 }, + { id: "clip-2", label: "Outro", tag: "div", start: 2, duration: 1, track: 1 }, + ], + gsapAnimations: new Map([ + [ + "clip-1", + [ + { + id: "position-tween", + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 20, + properties: {}, + propertyGroup: "position", + keyframes: { + format: "percentage", + keyframes: [{ percentage: 50, properties: { x: 100 } }], + }, + }, + ], + ], + ]), }); - const root = createRoot(host); - act(() => { - root.render(React.createElement(Timeline)); - }); + const { host, root, clip, trackHeader, rulerTick, rulerOrigin, playhead } = + renderTimelineGeometry("clip-1"); + const { trackHeader: collapsedHeader } = getHorizontalGeometry(host, "clip-2", "00:10"); + const diamond = host.querySelector( + '[data-keyframe-group="position"][data-keyframe-percentage="50"]', + ); + if (!diamond) throw new Error("Missing expanded position keyframe"); + const propertyLane = diamond.closest("[data-timeline-property-lane]"); + if (!propertyLane) throw new Error("Missing flat position property lane"); + const headerLane = trackHeader.querySelector('[data-property-group="position"]'); + if (!headerLane) throw new Error("Missing position property header"); + // Absolute x rebuilds from the content origin (the ruler-origin spacer), + // which now insets a GUTTER past the LABEL_COL_W label column so a 0% + // diamond has room to its left. The content row reaches that same origin via + // header (LABEL_COL_W) + its gutter margin, so ruler tick and diamond still + // coincide on the shared time x. + const contentOrigin = Number.parseFloat(rulerOrigin.style.width); + const rulerX = contentOrigin + Number.parseFloat(rulerTick.style.left) + 0.5; + const diamondX = + contentOrigin + + Number.parseFloat(propertyLane.style.left) + + Number.parseFloat(diamond.style.left) + + Number.parseFloat(diamond.style.width) / 2; + + expect(clip.contains(propertyLane)).toBe(false); + expect(clip.style.height).toBe(`${TRACK_H - 2 * CLIP_Y}px`); + expect(clip.style.bottom).toBe(""); + expect(propertyLane.style.top).toBe(`${getTimelineLaneTop(0)}px`); + expect(propertyLane.style.top).toBe(headerLane.style.top); + expect(propertyLane.style.background).toBe(""); + expect(propertyLane.style.border).toBe(""); + expect(propertyLane.style.borderRadius).toBe(""); + expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`); + expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`); + expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`); + expect(diamondX).toBe(rulerX); + expect(rulerX).toBe(LABEL_COL_W + GUTTER + 1000); + expect(collapsedHeader.textContent).toContain("Outro"); + expect(getTimelineFitPps(640, 20, LABEL_COL_W + GUTTER)).toBeCloseTo( + (640 - (LABEL_COL_W + GUTTER) - 2) / MIN_TIMELINE_EXTENT_S, + ); + expect( + resolveTimelineAssetDrop( + { + rectLeft: 100, + rectTop: 0, + scrollLeft: 0, + scrollTop: 0, + contentOrigin: LABEL_COL_W + GUTTER, + pixelsPerSecond: 100, + duration: 60, + trackOrder: [0], + }, + 100 + LABEL_COL_W + GUTTER + 1000, + 100, + ).start, + ).toBe(10); + + act(() => root.unmount()); + }); + + it("renders the public Timeline export without TimelineEditProvider", () => { + const { root } = renderBasicTimeline(); + + act(() => root.unmount()); + }); + + it("renders the gutter without legacy icons or hue dots", () => { + const { host, root } = renderBasicTimeline(); const hueDot = Array.from(host.querySelectorAll("div")).find( (node) => @@ -99,14 +267,8 @@ describe("Timeline provider boundary", () => { act(() => root.unmount()); }); - // fallow-ignore-next-line code-duplication it("requests persisted track visibility from the gutter without seeking", () => { - const host = document.createElement("div"); - document.body.append(host); - Object.defineProperty(host, "clientWidth", { - configurable: true, - value: 640, - }); + const host = createSizedTimelineHost(640); usePlayerStore.setState({ duration: 4, @@ -153,8 +315,8 @@ describe("Timeline provider boundary", () => { }); const row = button.parentElement?.parentElement; - // Row children: [sticky gutter, TRACKS_LEFT_PAD spacer, time-mapped content]. - const trackContent = row?.children.item(2); + // Row children: [TimelineTrackHeader (sticky column), time-mapped content]. + const trackContent = row?.children.item(1); expect(onToggleTrackHidden).toHaveBeenCalledWith(0, false); expect(trackContent).toBeInstanceOf(HTMLElement); if (!(trackContent instanceof HTMLElement)) { @@ -165,14 +327,49 @@ describe("Timeline provider boundary", () => { act(() => root.unmount()); }); - it("opens the keyframe context menu without seeking to that keyframe", () => { - const host = document.createElement("div"); - document.body.append(host); - Object.defineProperty(host, "clientWidth", { - configurable: true, - value: 720, + it("splits all tracks once when shift-clicking the timeline with the razor", () => { + const host = createSizedTimelineHost(640); + usePlayerStore.setState({ + activeTool: "razor", + duration: 4, + timelineReady: true, + elements: [{ id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }], }); + const onRazorSplitAll = vi.fn(); + const root = createRoot(host); + act(() => { + root.render( + React.createElement( + TimelineEditProvider, + { value: { onRazorSplitAll } }, + React.createElement(Timeline), + ), + ); + }); + + const viewport = host.querySelector('[aria-label="Timeline"]')?.firstElementChild; + expect(viewport).toBeInstanceOf(HTMLElement); + act(() => { + viewport?.dispatchEvent( + new MouseEvent("pointerdown", { + bubbles: true, + cancelable: true, + button: 0, + clientX: 240, + shiftKey: true, + }), + ); + }); + + expect(onRazorSplitAll).toHaveBeenCalledTimes(1); + expect(onRazorSplitAll).toHaveBeenCalledWith(expect.any(Number)); + act(() => root.unmount()); + }); + + it("opens the keyframe context menu without seeking to that keyframe", () => { + const host = createSizedTimelineHost(720); + usePlayerStore.setState({ duration: 4, timelineReady: true, @@ -215,14 +412,101 @@ describe("Timeline provider boundary", () => { act(() => root.unmount()); }); - it("marks every clip in selectedElementIds as selected", () => { - const host = document.createElement("div"); - document.body.append(host); - Object.defineProperty(host, "clientWidth", { - configurable: true, - value: 720, + it("shows a disclosure only for grouped keyframes and toggles the track height", () => { + const host = createSizedTimelineHost(720); + + usePlayerStore.setState({ + duration: 4, + timelineReady: true, + elements: [ + { id: "clip-1", tag: "div", start: 0, duration: 2, track: 0 }, + { id: "clip-2", tag: "div", start: 2, duration: 2, track: 1 }, + ], + keyframeCache: new Map([ + [ + "clip-1", + { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 }, propertyGroup: "position" }, + { percentage: 50, properties: { x: 100 }, propertyGroup: "position" }, + { percentage: 100, properties: { opacity: 0 }, propertyGroup: "visual" }, + ], + }, + ], + ]), + gsapAnimations: new Map([ + [ + "clip-1", + [ + { + id: "clip-1-position", + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 2, + properties: {}, + propertyGroup: "position", + keyframes: { + format: "percentage", + keyframes: [ + { percentage: 0, properties: { x: 0 } }, + { percentage: 50, properties: { x: 100 } }, + ], + }, + }, + { + id: "clip-1-visual", + targetSelector: "#clip-1", + method: "to", + position: 0, + duration: 2, + properties: {}, + propertyGroup: "visual", + keyframes: { + format: "percentage", + keyframes: [{ percentage: 100, properties: { opacity: 0 } }], + }, + }, + ], + ], + ]), }); + const root = createRoot(host); + act(() => { + root.render(React.createElement(Timeline)); + }); + + // Keyframed clip-1 is expanded by default (AE/Figma default); its disclosure + // lives in the left column. clip-2 has no keyframes so it never shows one. + const collapseButton = host.querySelector( + 'button[aria-label="Collapse clip-1 keyframes"]', + ); + expect(collapseButton).not.toBeNull(); + expect(host.querySelector('button[aria-label="Expand clip-2 keyframes"]')).toBeNull(); + expect(host.querySelector('button[aria-label="Collapse clip-2 keyframes"]')).toBeNull(); + + const clip = host.querySelector('[data-el-id="clip-1"]'); + const row = clip?.parentElement?.parentElement; + expectTrackExpansion(row, ["clip-1"], TRACK_H + 2 * LANE_H); + + // Collapsing sticks (does not bounce back open via auto-expand). + act(() => collapseButton?.click()); + expectTrackExpansion(row, [], TRACK_H); + + const expandButton = host.querySelector( + 'button[aria-label="Expand clip-1 keyframes"]', + ); + expect(expandButton).not.toBeNull(); + act(() => expandButton?.click()); + expectTrackExpansion(row, ["clip-1"], TRACK_H + 2 * LANE_H); + act(() => root.unmount()); + }); + + it("marks every clip in selectedElementIds as selected", () => { + const host = createSizedTimelineHost(720); + usePlayerStore.setState({ duration: 6, timelineReady: true, @@ -461,23 +745,29 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => { it("computes fit pps against the 60s floor for short compositions", () => { // A 10s comp maps 60s onto the viewport → the comp takes ~1/6 of the width. // (10 * 1.2 = 12s of headroom-padded content is still under the 60s floor.) - const pps = getTimelineFitPps(viewport, 10); - expect(pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S); - expect(10 * pps).toBeCloseTo((viewport - GUTTER - TRACKS_LEFT_PAD - 2) / 6); + const pps = getTimelineFitPps(viewport, 10, GUTTER + TRACKS_LEFT_PAD); + expect(pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S); + expect(10 * pps).toBeCloseTo((viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / 6); }); it("fits duration * FIT_ZOOM_HEADROOM (not the bare duration) for long compositions", () => { - expect(getTimelineFitPps(viewport, 60)).toBeCloseTo( - (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (60 * FIT_ZOOM_HEADROOM), + expect(getTimelineFitPps(viewport, 60, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo( + (viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / (60 * FIT_ZOOM_HEADROOM), + ); + expect(getTimelineFitPps(viewport, 120, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo( + (viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / (120 * FIT_ZOOM_HEADROOM), ); - expect(getTimelineFitPps(viewport, 120)).toBeCloseTo( - (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / (120 * FIT_ZOOM_HEADROOM), + }); + + it("subtracts the expanded keyframe label column before fitting headroom", () => { + expect(getTimelineFitPps(viewport, 120, LABEL_COL_W)).toBeCloseTo( + (viewport - LABEL_COL_W - 2) / (120 * FIT_ZOOM_HEADROOM), ); }); it("leaves CapCut-style trailing headroom: the comp ends at 1/1.2 of the usable width", () => { - const usable = viewport - GUTTER - TRACKS_LEFT_PAD - 2; - const pps = getTimelineFitPps(viewport, 120); + const usable = viewport - (GUTTER + TRACKS_LEFT_PAD) - 2; + const pps = getTimelineFitPps(viewport, 120, GUTTER + TRACKS_LEFT_PAD); // Composition content occupies usable/1.2 px; the remaining ~17% is empty // droppable ruler/lane surface past the end. expect(120 * pps).toBeCloseTo(usable / FIT_ZOOM_HEADROOM); @@ -485,17 +775,17 @@ describe("getTimelineFitPps (min 60s extent + fit headroom)", () => { }); it("falls back to 100 pps before the viewport is measured", () => { - expect(getTimelineFitPps(0, 10)).toBe(100); - expect(getTimelineFitPps(GUTTER + TRACKS_LEFT_PAD, 10)).toBe(100); - expect(getTimelineFitPps(Number.NaN, 10)).toBe(100); + expect(getTimelineFitPps(0, 10, GUTTER)).toBe(100); + expect(getTimelineFitPps(GUTTER, 10, GUTTER)).toBe(100); + expect(getTimelineFitPps(Number.NaN, 10, GUTTER)).toBe(100); }); it("uses the floor for zero/invalid durations", () => { - expect(getTimelineFitPps(viewport, 0)).toBeCloseTo( - (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S, + expect(getTimelineFitPps(viewport, 0, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo( + (viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S, ); - expect(getTimelineFitPps(viewport, Number.NaN)).toBeCloseTo( - (viewport - GUTTER - TRACKS_LEFT_PAD - 2) / MIN_TIMELINE_EXTENT_S, + expect(getTimelineFitPps(viewport, Number.NaN, GUTTER + TRACKS_LEFT_PAD)).toBeCloseTo( + (viewport - (GUTTER + TRACKS_LEFT_PAD) - 2) / MIN_TIMELINE_EXTENT_S, ); }); }); @@ -504,14 +794,24 @@ describe("getTimelineDisplayContentWidth", () => { it("always spans at least MIN_TIMELINE_EXTENT_S seconds of content", () => { // 10s of content at 20 pps = 200px; the floor keeps 60s (1200px) rendered. expect( - getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 400, pps: 20 }), + getTimelineDisplayContentWidth({ + trackContentWidth: 200, + viewportWidth: 400, + contentOrigin: GUTTER, + pps: 20, + }), ).toBe(MIN_TIMELINE_EXTENT_S * 20); }); it("still fills the viewport when that is larger than the 60s floor", () => { expect( - getTimelineDisplayContentWidth({ trackContentWidth: 200, viewportWidth: 2000, pps: 5 }), - ).toBe(2000 - GUTTER - TRACKS_LEFT_PAD - 2); + getTimelineDisplayContentWidth({ + trackContentWidth: 200, + viewportWidth: 2000, + contentOrigin: GUTTER + TRACKS_LEFT_PAD, + pps: 5, + }), + ).toBe(2000 - (GUTTER + TRACKS_LEFT_PAD) - 2); }); it("tracks a drag ghost past every other bound (drag-to-extend)", () => { @@ -519,6 +819,7 @@ describe("getTimelineDisplayContentWidth", () => { getTimelineDisplayContentWidth({ trackContentWidth: 500, viewportWidth: 400, + contentOrigin: GUTTER, pps: 5, dragGhostEndPx: 5000, }), @@ -530,6 +831,7 @@ describe("getTimelineDisplayContentWidth", () => { getTimelineDisplayContentWidth({ trackContentWidth: 500, viewportWidth: 400, + contentOrigin: GUTTER, pps: 5, resizeGhostEndPx: 4200, }), @@ -538,7 +840,12 @@ describe("getTimelineDisplayContentWidth", () => { it("keeps long content authoritative", () => { expect( - getTimelineDisplayContentWidth({ trackContentWidth: 9000, viewportWidth: 400, pps: 50 }), + getTimelineDisplayContentWidth({ + trackContentWidth: 9000, + viewportWidth: 400, + contentOrigin: GUTTER, + pps: 50, + }), ).toBe(9000); }); }); @@ -565,7 +872,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => { getTimelineScrollLeftForZoomAnchor({ pointerX: 300, currentScrollLeft: 200, - gutter: 32, + contentOrigin: GUTTER, currentPixelsPerSecond: 10, nextPixelsPerSecond: 20, duration: 120, @@ -578,7 +885,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => { getTimelineScrollLeftForZoomAnchor({ pointerX: 300, currentScrollLeft: 0, - gutter: 32, + contentOrigin: GUTTER, currentPixelsPerSecond: 20, nextPixelsPerSecond: 5, duration: 120, @@ -591,7 +898,7 @@ describe("getTimelineScrollLeftForZoomAnchor", () => { getTimelineScrollLeftForZoomAnchor({ pointerX: 300, currentScrollLeft: 120, - gutter: 32, + contentOrigin: GUTTER, currentPixelsPerSecond: 0, nextPixelsPerSecond: 20, duration: 120, @@ -601,38 +908,59 @@ describe("getTimelineScrollLeftForZoomAnchor", () => { }); describe("getTimelinePlayheadLeft", () => { - it("offsets the wrapper by half the head width so the line CENTER = GUTTER + TRACKS_LEFT_PAD + t*pps", () => { + it("offsets the wrapper by half the head width so the line CENTER = contentOrigin + t*pps", () => { // Wrapper left + PLAYHEAD_HEAD_W/2 (where the 1px line is centered) must - // equal GUTTER + TRACKS_LEFT_PAD + t*pps at any zoom. - expect(getTimelinePlayheadLeft(4, 20) + PLAYHEAD_HEAD_W / 2).toBe( + // equal contentOrigin + t*pps at any zoom, for both the padded default + // origin and the plain gutter origin. + expect(getTimelinePlayheadLeft(4, 20, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe( GUTTER + TRACKS_LEFT_PAD + 4 * 20, ); - expect(getTimelinePlayheadLeft(10, 7.5) + PLAYHEAD_HEAD_W / 2).toBe( + expect(getTimelinePlayheadLeft(10, 7.5, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe( GUTTER + TRACKS_LEFT_PAD + 75, ); + expect(getTimelinePlayheadLeft(4, 20, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 4 * 20); + expect(getTimelinePlayheadLeft(10, 7.5, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + 75); + }); + + it("uses the expanded keyframe label column as the playhead origin", () => { + expect(getTimelinePlayheadLeft(4, 20, LABEL_COL_W) + PLAYHEAD_HEAD_W / 2).toBe( + LABEL_COL_W + 4 * 20, + ); }); it("centers the line exactly on the left pad's end (the 00:00 tick) at t = 0", () => { - expect(getTimelinePlayheadLeft(0, 20) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER + TRACKS_LEFT_PAD); + expect(getTimelinePlayheadLeft(0, 20, GUTTER + TRACKS_LEFT_PAD) + PLAYHEAD_HEAD_W / 2).toBe( + GUTTER + TRACKS_LEFT_PAD, + ); + }); + + it("centers the line exactly on the gutter (the 00:00 tick) at t = 0", () => { + expect(getTimelinePlayheadLeft(0, 20, GUTTER) + PLAYHEAD_HEAD_W / 2).toBe(GUTTER); }); it("guards invalid input", () => { - expect(getTimelinePlayheadLeft(Number.NaN, 20)).toBe( + expect(getTimelinePlayheadLeft(Number.NaN, 20, GUTTER + TRACKS_LEFT_PAD)).toBe( GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2, ); - expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe( + expect(getTimelinePlayheadLeft(4, Number.NaN, GUTTER + TRACKS_LEFT_PAD)).toBe( GUTTER + TRACKS_LEFT_PAD - PLAYHEAD_HEAD_W / 2, ); + expect(getTimelinePlayheadLeft(Number.NaN, 20, GUTTER)).toBe(GUTTER - PLAYHEAD_HEAD_W / 2); + expect(getTimelinePlayheadLeft(4, Number.NaN, LABEL_COL_W)).toBe( + LABEL_COL_W - PLAYHEAD_HEAD_W / 2, + ); }); }); describe("getTimelineCanvasHeight", () => { it("includes bottom scroll buffer below the last track", () => { - expect(getTimelineCanvasHeight(3)).toBeGreaterThan(RULER_H + 3 * TRACK_H); + expect(getTimelineCanvasHeight([TRACK_H, TRACK_H, TRACK_H])).toBeGreaterThan( + RULER_H + 3 * TRACK_H, + ); }); it("still keeps ruler space when there are no tracks", () => { - expect(getTimelineCanvasHeight(0)).toBeGreaterThan(24); + expect(getTimelineCanvasHeight([])).toBeGreaterThan(24); }); }); @@ -686,14 +1014,15 @@ describe("resolveTimelineAssetDrop", () => { rectTop: 200, scrollLeft: 0, scrollTop: 0, + contentOrigin: GUTTER, pixelsPerSecond: 100, duration: 10, trackHeight: 72, trackOrder: [0, 3, 7], }, - 480, // rectLeft(100) + GUTTER + TRACKS_LEFT_PAD + 3s*100pps - // clientY updated for TRACKS_TOP_PAD=72: rectTop(200) + RULER_H(24) + - // TRACKS_TOP_PAD(72) + TRACK_H(48) + TRACK_H/2(24) = 368 → row 1 → track 3. + 432, // rectLeft(100) + GUTTER(32) + 3s*100pps (contentOrigin = GUTTER) + // clientY: rectTop(200) + RULER_H(24) + TRACKS_TOP_PAD(72) + TRACK_H(48) + // + TRACK_H/2(24) = 368 → row 1 → track 3. 368, ), ).toEqual({ start: 3, track: 3 }); @@ -707,12 +1036,13 @@ describe("resolveTimelineAssetDrop", () => { rectTop: 200, scrollLeft: 0, scrollTop: 0, + contentOrigin: GUTTER, pixelsPerSecond: 100, duration: 10, trackHeight: 72, trackOrder: [0, 3, 7], }, - 250 + TRACKS_LEFT_PAD, + 250, // rectLeft(100) + GUTTER(32) + 1.18s*100pps (contentOrigin = GUTTER) 600, ), ).toEqual({ start: 1.18, track: 8 }); diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 0e847a3fab..5279eeb31c 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -9,7 +9,6 @@ import { defaultTimelineTheme } from "./timelineTheme"; import { useTimelineRangeSelection } from "./useTimelineRangeSelection"; import { useTimelinePlayhead } from "./useTimelinePlayhead"; import { useTimelineActiveClips } from "./useTimelineActiveClips"; -import { getTrackStyle } from "./timelineIcons"; import { useTimelineZoom } from "./useTimelineZoom"; import { useTimelineAssetDrop } from "./timelineDragDrop"; import { TimelineEmptyState } from "./TimelineEmptyState"; @@ -17,18 +16,27 @@ import { TimelineCanvas } from "./TimelineCanvas"; import { type KeyframeDiamondContextMenuState } from "./KeyframeDiamondContextMenu"; import { useTimelineClipDrag } from "./useTimelineClipDrag"; import { TimelineOverlays } from "./TimelineOverlays"; +import { animationContributesLane } from "./TimelinePropertyLanes"; import { useTimelineEditPinning } from "./useTimelineEditPinning"; import { useTimelineStackingSync } from "./useTimelineStackingSync"; import { useTimelineGeometry } from "./useTimelineGeometry"; -import { useTimelineTrackDerivations } from "./useTimelineTrackDerivations"; -import { GUTTER, TRACKS_LEFT_PAD, generateTicks, getTimelineCanvasHeight } from "./timelineLayout"; +import { useAutoExpandKeyframedClips } from "./useAutoExpandKeyframedClips"; +import { GUTTER, LABEL_COL_W, TRACKS_LEFT_PAD, generateTicks } from "./timelineLayout"; import { useTimelineScrollViewport } from "./useTimelineScrollViewport"; import { STUDIO_PREVIEW_FPS } from "../lib/time"; import { useResolvedTimelineEditCallbacks } from "./useResolvedTimelineEditCallbacks"; import type { TimelineProps } from "./TimelineTypes"; +import { + getTrackStyle, + useTimelineDisplayLayout, + useTimelineTrackLayout, +} from "./useTimelineTrackLayout"; +import { useTimelineKeyframeHandlers } from "./useTimelineKeyframeHandlers"; +import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; import { useTrackGapMenu } from "./useTrackGapMenu"; import { useTimelineGapHighlights } from "./useTimelineGapHighlights"; import { useStudioPlaybackContextOptional } from "../../contexts/StudioContext"; +import { TimelineRazorGuide, useTimelineRazorInteraction } from "./TimelineRazorInteraction"; // Re-export pure utilities so existing imports from "./Timeline" still resolve. export { @@ -74,7 +82,6 @@ export const Timeline = memo(function Timeline({ onRazorSplitAll, onDeleteKeyframe, onDeleteAllKeyframes, - onChangeKeyframeEase, onMoveKeyframeToPlayhead, onMoveKeyframe, } = useResolvedTimelineEditCallbacks({ @@ -106,6 +113,25 @@ export const Timeline = memo(function Timeline({ const timelineReady = usePlayerStore((s) => s.timelineReady); const selectedElementId = usePlayerStore((s) => s.selectedElementId); const selectedElementIds = usePlayerStore((s) => s.selectedElementIds); + const gsapAnimations = usePlayerStore((s) => s.gsapAnimations); + // Label mode = comp has keyframed clips (not just when expanded): keeps the layer + // disclosure + property column visible and reserves a GUTTER before 0s (Figma). + const hasKeyframedClips = useMemo( + () => + Array.from(gsapAnimations.values()).some((list) => + // Same lane-contribution predicate the layout uses: real keyframes OR a + // synthesizable flat tween. Checking animation.keyframes alone left a + // flat-tween-only comp without its reserved label column. + list.some((animation) => animationContributesLane(animation)), + ), + [gsapAnimations], + ); + const labelMode = STUDIO_KEYFRAMES_ENABLED && hasKeyframedClips; + // Without the label column the pre-t=0 breathing room is still TRACKS_LEFT_PAD + // (dropping it would jam clip 0 against the gutter on every non-keyframed + // composition); in label mode the 232px label column already provides it. + const contentOrigin = labelMode ? LABEL_COL_W + GUTTER : GUTTER + TRACKS_LEFT_PAD; + const contentGutter = labelMode ? GUTTER : 0; const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId); const currentTime = usePlayerStore((s) => s.currentTime); const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom(); @@ -117,7 +143,6 @@ export const Timeline = memo(function Timeline({ const [hoveredClip, setHoveredClip] = useState(null); const isDragging = useRef(false); const [shiftHeld, setShiftHeld] = useState(false); - const [razorGuideX, setRazorGuideX] = useState(null); useMountEffect(() => { const key = (e: KeyboardEvent) => e.key === "Shift" && setShiftHeld(e.type === "keydown"); @@ -154,9 +179,10 @@ export const Timeline = memo(function Timeline({ return Number.isFinite(result) ? result : safeDur; }, [rawElements, duration]); - const { tracks, trackStyles, trackOrder } = useTimelineTrackDerivations(expandedElements); - const trackOrderRef = useRef(trackOrder); - trackOrderRef.current = trackOrder; + const keyframeCache = usePlayerStore((s) => s.keyframeCache); + useAutoExpandKeyframedClips(gsapAnimations); + const { tracks, trackStyles, trackOrder, trackOrderRef, laneCounts, rowHeights, rowHeightsRef } = + useTimelineTrackLayout(expandedElements, gsapAnimations, selectedElementId, selectedElementIds); const expandedElementsRef = useRef(expandedElements); expandedElementsRef.current = expandedElements; @@ -223,6 +249,7 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, + rowHeightsRef, onMoveElement: pinnedOnMoveElement, onMoveElements: pinnedOnMoveElements, onResizeElement: pinnedOnResizeElement, @@ -241,26 +268,32 @@ export const Timeline = memo(function Timeline({ ppsRef, durationRef, trackOrderRef, + rowHeightsRef, + contentOrigin, onFileDrop: pinnedOnFileDrop, onAssetDrop: pinnedOnAssetDrop, onBlockDrop: pinnedOnBlockDrop, onCompositionDrop: pinnedOnCompositionDrop, }); - const displayTrackOrder = useMemo(() => { - if (!draggedClip?.started || trackOrder.includes(draggedClip.previewTrack)) return trackOrder; - return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b); - }, [draggedClip, trackOrder]); - - const totalH = getTimelineCanvasHeight(displayTrackOrder.length); + const displayLayout = useTimelineDisplayLayout(draggedClip, trackOrder, rowHeights); const { viewportWidth, showShortcutHint, setScrollRef } = useTimelineScrollViewport(scrollRef, [ timelineReady, expandedElements.length, - totalH, + displayLayout.totalH, ]); - const keyframeCache = usePlayerStore((s) => s.keyframeCache); const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes); const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe); + const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } = + useTimelineKeyframeHandlers({ + expandedElements, + keyframeCache, + onSelectElement, + onSeek, + setSelectedElementId, + setKfContextMenu, + toggleSelectedKeyframe, + }); const selectedElement = useMemo( () => @@ -291,6 +324,7 @@ export const Timeline = memo(function Timeline({ isDragging, scrollRef, lastScrollLeftRef, + contentOrigin, }); const laneGapStrips = useTimelineGapHighlights({ @@ -323,12 +357,21 @@ export const Timeline = memo(function Timeline({ setZoomMode, setManualZoomPercent, onSeek, + contentOrigin, }); useTimelineActiveClips({ scrollRef, currentTime, clipStateVersion, }); + const { razorGuideX, updateRazorGuide, clearRazorGuide, splitAllAtPointer } = + useTimelineRazorInteraction({ + active: activeTool === "razor", + scrollRef, + contentOrigin, + pixelsPerSecond: pps, + onSplitAll: onRazorSplitAll, + }); const { rangeSelection, @@ -352,7 +395,9 @@ export const Timeline = memo(function Timeline({ setShowPopover, elementsRef: expandedElementsRef, trackOrderRef, + rowHeightsRef, onSelectElement, + contentOrigin, }); setRangeSelectionRef.current = setRangeSelection; // stable ref consumed by useTimelineClipDrag @@ -411,13 +456,8 @@ export const Timeline = memo(function Timeline({ ref={setContainerRef} aria-label="Timeline" className={`relative border-t select-none h-full overflow-hidden ${isDragOver ? "ring-1 ring-inset ring-studio-accent/60" : ""} ${activeTool === "razor" ? "cursor-crosshair" : shiftHeld ? "cursor-crosshair" : "cursor-default"}`} - onMouseMove={(e) => { - if (activeTool === "razor" && scrollRef.current) { - const rect = scrollRef.current.getBoundingClientRect(); - setRazorGuideX(e.clientX - rect.left + scrollRef.current.scrollLeft); - } - }} - onMouseLeave={() => setRazorGuideX(null)} + onMouseMove={updateRazorGuide} + onMouseLeave={clearRazorGuide} style={{ touchAction: "pan-x pan-y", background: theme.shellBackground, @@ -435,14 +475,10 @@ export const Timeline = memo(function Timeline({ onDragLeave={() => clearDropPreview()} onDrop={handleAssetDrop} onPointerDown={(e) => { - if (activeTool === "razor" && e.shiftKey && e.button === 0 && scrollRef.current) { - const rect = scrollRef.current.getBoundingClientRect(); - const x = - e.clientX - rect.left + scrollRef.current.scrollLeft - GUTTER - TRACKS_LEFT_PAD; - const splitTime = Math.max(0, x / pps); - onRazorSplitAll?.(splitTime); - return; - } + // Let interactive controls (keyframe nav/toggle, caret, inputs) handle + // their own clicks — scrubbing here would preventDefault and eat them. + if (e.target instanceof Element && e.target.closest("button, input, select, a")) return; + if (splitAllAtPointer(e)) return; handlePointerDown(e); }} onPointerMove={handlePointerMove} @@ -453,18 +489,22 @@ export const Timeline = memo(function Timeline({ major={major} minor={minor} pps={pps} + contentOrigin={contentOrigin} + contentGutter={contentGutter} trackContentWidth={displayContentWidth} - totalH={totalH} + totalH={displayLayout.totalH} effectiveDuration={effectiveDuration} majorTickInterval={majorTickInterval} rangeSelection={rangeSelection} marqueeRect={marqueeRect} laneGapStrips={laneGapStrips} theme={theme} - displayTrackOrder={displayTrackOrder} + displayTrackOrder={displayLayout.displayTrackOrder} + rowHeights={displayLayout.displayRowHeights} trackOrder={trackOrder} tracks={tracks} trackStyles={trackStyles} + laneCounts={laneCounts} selectedElementId={selectedElementId} selectedElementIds={selectedElementIds} hoveredClip={hoveredClip} @@ -490,43 +530,16 @@ export const Timeline = memo(function Timeline({ getPreviewElement={getPreviewElement} getTrackStyle={getTrackStyle} keyframeCache={keyframeCache} + gsapAnimations={gsapAnimations} selectedKeyframes={selectedKeyframes} currentTime={currentTime} + onSeek={onSeek} beatAnalysis={adjustedBeatAnalysis} - onClickKeyframe={(el, pct) => { - usePlayerStore.getState().clearSelectedKeyframes(); - const elKey = el.key ?? el.id; - setSelectedElementId(elKey); - onSelectElement?.(el); - // Select the clicked diamond (matches shift-click); cleared above so this single-selects. - toggleSelectedKeyframe(`${elKey}:${pct}`); - const absTime = el.start + (pct / 100) * el.duration; - onSeek?.(absTime); - const kfData = keyframeCache?.get(elKey); - const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.5); - usePlayerStore.getState().setActiveKeyframePct(kf?.tweenPercentage ?? null); - }} - onShiftClickKeyframe={(elId, pct) => { - toggleSelectedKeyframe(`${elId}:${pct}`); - }} + onSelectSegment={onSelectSegment} + onClickKeyframe={onClickKeyframe} + onShiftClickKeyframe={onShiftClickKeyframe} onMoveKeyframe={onMoveKeyframe} - onContextMenuKeyframe={(e, elId, pct) => { - const el = expandedElements.find((x) => (x.key ?? x.id) === elId); - if (el) { - setSelectedElementId(elId); - onSelectElement?.(el); - } - const kfData = keyframeCache.get(elId); - const kf = kfData?.keyframes.find((k) => Math.abs(k.percentage - pct) < 0.2); - setKfContextMenu({ - x: e.clientX + 4, - y: e.clientY + 2, - elementId: elId, - percentage: pct, - tweenPercentage: kf?.tweenPercentage, - currentEase: kf?.ease ?? kfData?.ease, - }); - }} + onContextMenuKeyframe={onContextMenuKeyframe} onContextMenuClip={(e, el) => { e.preventDefault(); setSelectedElementId(el.key ?? el.id); @@ -540,16 +553,7 @@ export const Timeline = memo(function Timeline({ openGapMenu({ x: e.clientX, y: e.clientY, track, time }); }} /> - {activeTool === "razor" && razorGuideX !== null && ( -
- )} + {activeTool === "razor" && razorGuideX !== null && }
s.beatDragging); // Scroll a clip into view when the sidebar (asset card) requests a reveal. useTimelineRevealClip(scrollRef); @@ -63,8 +76,6 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas // The drag ghost follows the cursor freely (both axes) — CapCut-style. The // "magnetic" affordance is a highlight on the destination lane (draggedRowIndex), // which flips at the MAGNETIC_TRACK_THRESHOLD point; the clip drops into it. - const draggedRowIndex = - draggedClip?.started === true ? displayTrackOrder.indexOf(draggedClip.previewTrack) : -1; // Live multi-selection drag: while a selected clip is dragged, ALL selected // clips move together as one rigid formation. The GRABBED clip is the free // ghost below; its co-selected "passengers" slide by the SAME group-clamped @@ -101,7 +112,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas return (
{/* Breathing room between the sticky ruler and the first track lane — the @@ -124,6 +136,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas draggedElement={draggedElement} multiDragPreview={multiDragPreview} onToggleTrackHidden={onToggleTrackHidden} + onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe} onResizeElement={onResizeElement} onMoveElement={onMoveElement} onRazorSplit={onRazorSplit} @@ -148,8 +161,8 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas key={`gap-${strip.kind}-${strip.track}-${gap.start}`} className="pointer-events-none absolute" style={{ - top: getTimelineRowTop(rowIndex) + CLIP_Y, - left: GUTTER + TRACKS_LEFT_PAD + gap.start * props.pps, + top: getTimelineRowTop(rowIndex, props.rowHeights) + CLIP_Y, + left: props.contentOrigin + gap.start * props.pps, width: Math.max((gap.end - gap.start) * props.pps, 2), height: TRACK_H - CLIP_Y * 2, background: loud ? "rgba(60,230,172,0.18)" : "rgba(60,230,172,0.055)", @@ -166,10 +179,10 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas
@@ -280,8 +293,7 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas className="absolute pointer-events-none" style={{ left: - GUTTER + - TRACKS_LEFT_PAD + + props.contentOrigin + Math.min(props.rangeSelection.start, props.rangeSelection.end) * props.pps, width: Math.abs(props.rangeSelection.end - props.rangeSelection.start) * props.pps, top: RULER_H, @@ -297,13 +309,13 @@ export const TimelineCanvas = memo(function TimelineCanvas(props: TimelineCanvas {/* Playhead — hidden while dragging a beat so its guideline doesn't track the scrub and clutter the beat being moved. Explicit width + the half-head offset baked into getTimelinePlayheadLeft keep the - inner 1px line's CENTER exactly on GUTTER + t * pps (the ruler + inner 1px line's CENTER exactly on contentOrigin + t * pps (the ruler ticks' center), instead of relying on shrink-wrap sizing. */}
{ + // Dense rows narrow the DIAMOND so neighbours stay individually readable, but + // the hit box floors at KF_MIN_HIT_W — a gap-sized target gets unusable + // (~7px) at the zoom floor. + it("narrows dense keyframe visuals while flooring their hit regions", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + ({ + percentage, + propertyGroup: "position", + properties: { x: percentage }, + })), + }} + clipWidthPx={36} + clipHeightPx={48} + accentColor="#4ba3d2" + isSelected + currentPercentage={0} + elementId="clip-1" + selectedKeyframes={new Set()} + groupAware + />, + ); + }); + + const diamonds = Array.from(host.querySelectorAll("button[title]")); + expect(diamonds).toHaveLength(3); + for (const diamond of diamonds) { + expect(Number.parseFloat(diamond.style.width)).toBeCloseTo(12); + expect(Number(diamond.querySelector("svg")?.getAttribute("width"))).toBeCloseTo(8.8); + } + act(() => root.unmount()); + }); + it("treats primary pointerup without drag as a keyframe click", () => { const { host, root, onClickKeyframe } = renderDiamonds(); const diamond = host.querySelector('button[title="50%"]'); @@ -54,7 +94,57 @@ describe("TimelineClipDiamonds", () => { diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 })); }); - expect(onClickKeyframe).toHaveBeenCalledWith(50); + expect(onClickKeyframe).toHaveBeenCalledWith( + "clip-1", + expect.objectContaining({ percentage: 50 }), + ); + act(() => root.unmount()); + }); + + // The collapsed clip row and the expanded property lanes read the same cache, + // so a keyframe that carries a property group has to hash to the same key in + // both — otherwise collapsing a track silently drops the selection. + it("keys a grouped keyframe the same way collapsed as expanded", () => { + const groupedKeyframe = { + percentage: 50, + tweenPercentage: 25, + propertyGroup: "position", + animationId: "anim-1", + properties: { x: 100 }, + }; + const sharedKey = timelineKeyframeSelectionKey("clip-1", groupedKeyframe); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const onClickKeyframe = vi.fn(); + act(() => { + root.render( + , + ); + }); + const diamond = host.querySelector('button[title="50%"]'); + // Highlighted from the shared key alone (the playhead is off-clip here). + expect(diamond?.querySelector("path:last-child")?.getAttribute("fill")).toBe("#4ba3d2"); + + act(() => { + diamond?.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0 })); + }); + expect(onClickKeyframe).toHaveBeenCalledWith("clip-1", { + percentage: 50, + tweenPercentage: 25, + propertyGroup: "position", + animationId: "anim-1", + }); act(() => root.unmount()); }); @@ -84,12 +174,24 @@ describe("TimelineClipDiamonds", () => { const root = createRoot(host); act(() => { root.render( - { selectedKeyframes={new Set()} onClickKeyframe={onClickKeyframe} onMoveKeyframe={onMoveKeyframe} + groupAware />, ); }); @@ -117,7 +220,12 @@ describe("TimelineClipDiamonds", () => { diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 })); }); - expect(onClickKeyframe).toHaveBeenCalledWith(50); + expect(onClickKeyframe).toHaveBeenCalledWith({ + percentage: 50, + tweenPercentage: 100, + propertyGroup: "position", + animationId: "anim-1", + }); expect(onMoveKeyframe).not.toHaveBeenCalled(); act(() => root.unmount()); }); @@ -126,20 +234,39 @@ describe("TimelineClipDiamonds", () => { // keyframe) committed the move but never selected/parked on the result — // the diamond it was just dragged looked exactly like one nothing happened // to. Select it at its NEW position too. - it("selects the keyframe at its new position after a real drag-retime", () => { + it("reselects a retimed keyframe with its post-move tween percentage", () => { const onClickKeyframe = vi.fn(); - const onMoveKeyframe = vi.fn(); + const onMoveKeyframe = vi.fn().mockResolvedValue(true); const host = document.createElement("div"); document.body.append(host); const root = createRoot(host); act(() => { root.render( - { selectedKeyframes={new Set()} onClickKeyframe={onClickKeyframe} onMoveKeyframe={onMoveKeyframe} + groupAware + />, + ); + }); + const diamond = host.querySelector('button[title="40%"]'); + expect(diamond).not.toBeNull(); + + act(() => { + diamond!.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }), + ); + diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 })); + }); + + expect(onMoveKeyframe).toHaveBeenCalledWith( + { + percentage: 40, + tweenPercentage: 50, + propertyGroup: "position", + animationId: "anim-1", + }, + 50, + ); + expect(onClickKeyframe).toHaveBeenCalledWith({ + percentage: 50, + tweenPercentage: 75, + propertyGroup: "position", + animationId: "anim-1", + }); + act(() => root.unmount()); + }); + + it("leaves the selection alone when a stale retime fails after a newer drag", async () => { + const onClickKeyframe = vi.fn(); + let failFirstDrag: (() => void) | undefined; + const onMoveKeyframe = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + failFirstDrag = () => resolve(false); + }), + ) + .mockResolvedValue(true); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const diamond = host.querySelector('button[title="50%"]'); + + // Two drags back to back; the first one's commit is still in flight. + act(() => { + diamond!.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }), + ); + diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 })); + diamond!.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150 }), + ); + diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170 })); + }); + onClickKeyframe.mockClear(); + + await act(async () => { + failFirstDrag?.(); + await Promise.resolve(); + }); + + // The stale failure must not drag the selection back to the first drag's + // source: the second retime, which the user can see, owns it now. + expect(onClickKeyframe).not.toHaveBeenCalled(); + act(() => root.unmount()); + }); + + it("composes a rapid second retime from the pending position", () => { + const onMoveKeyframe = vi.fn().mockResolvedValue(true); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , ); }); @@ -161,13 +446,156 @@ describe("TimelineClipDiamonds", () => { diamond!.dispatchEvent( pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }), ); - // 4px at a 200px clip width is 2 clip-% — well past the no-op epsilon, - // a real retime. - diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 104 })); + diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 })); + + // The cache still exposes 50%, but this second +10% drag starts at the + // pending 75% destination and must therefore land at 85%, not 60%. + diamond!.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 150 }), + ); + diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 170 })); + }); + + // The second move must identify the FROM keyframe by the pending (already- + // moved) position 75%, not the stale rendered 50%; otherwise the serialized + // mutation can't locate the keyframe the first move relocated. + expect(onMoveKeyframe).toHaveBeenNthCalledWith( + 2, + { + percentage: 75, + tweenPercentage: 75, + propertyGroup: "position", + animationId: "anim-1", + }, + 85, + ); + act(() => root.unmount()); + }); + + it.each([ + ["returns false", () => Promise.resolve(false)], + ["rejects", () => Promise.reject(new Error("retime failed"))], + ])("clears a failed pending retime when the callback %s", async (_label, settle) => { + const onMoveKeyframe = vi.fn().mockImplementationOnce(settle).mockResolvedValue(true); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const diamond = host.querySelector('button[title="50%"]'); + expect(diamond).not.toBeNull(); + + await act(async () => { + diamond!.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }), + ); + diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 150 })); + await Promise.resolve(); }); - expect(onMoveKeyframe).toHaveBeenCalledWith("clip-1", 50, 52); - expect(onClickKeyframe).toHaveBeenCalledWith(52); + act(() => { + diamond!.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 100 }), + ); + diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 120 })); + }); + + expect(onMoveKeyframe).toHaveBeenNthCalledWith( + 2, + { + percentage: 50, + tweenPercentage: 50, + propertyGroup: "position", + animationId: "anim-1", + }, + 60, + ); + act(() => root.unmount()); + }); + + it("cancels an in-flight retime on Escape without committing or selecting", () => { + const onClickKeyframe = vi.fn(); + const onMoveKeyframe = vi.fn().mockResolvedValue(true); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => { + root.render( + , + ); + }); + const diamond = host.querySelector('button[title="40%"]'); + expect(diamond).not.toBeNull(); + + act(() => { + diamond!.dispatchEvent( + pointerEvent("pointerdown", { bubbles: true, button: 0, clientX: 80 }), + ); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + diamond!.dispatchEvent(pointerEvent("pointerup", { bubbles: true, button: 0, clientX: 100 })); + }); + + // Escape ends the gesture: no retime is written, and the release is not + // reinterpreted as a click that would park the playhead on the keyframe. + expect(onMoveKeyframe).not.toHaveBeenCalled(); + expect(onClickKeyframe).not.toHaveBeenCalled(); act(() => root.unmount()); }); @@ -212,4 +640,100 @@ describe("TimelineClipDiamonds", () => { expect(suppressClickRef.current).toBe(true); act(() => root.unmount()); }); + + const renderSegmentLane = (lastAmbiguous: boolean) => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const kf = (percentage: number, extra: Record = {}) => ({ + percentage, + tweenPercentage: percentage, + propertyGroup: "position", + animationId: "anim-1", + properties: { x: percentage }, + ...extra, + }); + act(() => { + root.render( + , + ); + }); + return { host, root }; + }; + + it("hides the inline ease button on an ambiguous merged segment", () => { + // Segments 0->50 and 50->100; the 50->100 segment ends on the ambiguous + // keyframe, so its hover/ease-button area is not rendered. + const { host, root } = renderSegmentLane(true); + expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1); + act(() => root.unmount()); + }); + + it("keeps the inline ease button on unambiguous merged segments", () => { + const { host, root } = renderSegmentLane(false); + expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2); + act(() => root.unmount()); + }); + + it("keeps the ease button above nearby diamonds without blocking the segment", () => { + const { host, root } = renderSegmentLane(false); + const segment = host.querySelector("[data-keyframe-ease-segment]"); + const ease = segment?.querySelector("[data-keyframe-ease-button]"); + const diamond = host.querySelector('button[title="50%"]'); + + expect(segment?.style.pointerEvents).toBe("none"); + expect(ease?.style.pointerEvents).toBe("auto"); + expect(Number(segment?.style.zIndex)).toBeGreaterThan(Number(diamond?.style.zIndex)); + act(() => root.unmount()); + }); + + it("hides the inline ease button on a segment with no source animation id", () => { + // A runtime-scanned keyframe has no animationId, so there is no tween to + // target; the segment ending on it must not render a (dead) ease button. + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + const kf = (percentage: number, animationId?: string) => ({ + percentage, + tweenPercentage: percentage, + propertyGroup: "position", + ...(animationId ? { animationId } : {}), + properties: { x: percentage }, + }); + act(() => { + root.render( + , + ); + }); + expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(1); + act(() => root.unmount()); + }); }); diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index 129104dfb8..2f6ec31b8d 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -1,72 +1,40 @@ -import { memo, useRef, useState } from "react"; +import { memo, useEffect, useRef, useState } from "react"; import { BEAT_BAND_H } from "./BeatStrip"; import { KEYFRAME_DRAG_THRESHOLD_PX, previewClipPct, resolveKeyframeDrag, } from "../../components/editor/keyframeDrag"; +import { TimelineDiamondConnectors } from "./TimelineDiamondConnectors"; +import { clipToTweenPercentage } from "../../components/editor/KeyframeNavigation"; +import { LANE_H } from "./timelineLayout"; +import { timelineKeyframeSelectionKey } from "./timelineKeyframeIdentity"; +import { + DIAMOND_RATIO, + KF_MAX_PCT, + KF_MIN_PCT, + keyframeTarget, + type DragState, + type TimelineClipDiamondsProps, + type TimelineDiamondKeyframe, + type TimelineDiamondLaneProps, +} from "./timelineDiamondTypes"; -interface KeyframeEntry { - percentage: number; - /** Tween-relative percentage (the retime mutation keys on this, not clip %). */ - tweenPercentage?: number; - properties: Record; - ease?: string; -} +export type { TimelineDiamondKeyframe } from "./timelineDiamondTypes"; -interface KeyframeCacheEntry { - format: string; - keyframes: KeyframeEntry[]; - ease?: string; - easeEach?: string; -} +// Floor for a diamond's clickable width. The visual size still narrows to the +// neighbour gap so packed diamonds stay individually readable, but the hit box +// stops there: at the zoom floor the gap alone left a ~7px target, which is +// neither hittable nor selectable with any accuracy. Boxes may overlap slightly +// below this width; each diamond still owns the half-gap around its own centre. +const KF_MIN_HIT_W = 12; -interface TimelineClipDiamondsProps { - keyframesData: KeyframeCacheEntry; - clipWidthPx: number; - clipHeightPx: number; - /** Beat-dot strip is shown on this track → shrink diamonds + drop them into - * the bottom half so they clear the strip at the top. */ - beatsActive?: boolean; - accentColor: string; - isSelected: boolean; - currentPercentage: number; - elementId: string; - selectedKeyframes: Set; - onClickKeyframe?: (percentage: number) => void; - onShiftClickKeyframe?: (elementId: string, percentage: number) => void; - onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; - /** Drag-to-retime: move a keyframe to a new time, preserving its value + ease. - * Both percentages are clip-relative: `fromClipPercentage` identifies the - * dragged keyframe, `toClipPercentage` is the neighbour-clamped drop position. - * The handler decides move (within the tween) vs resize (past its boundary). */ - onMoveKeyframe?: ( - elementId: string, - fromClipPercentage: number, - toClipPercentage: number, - ) => void; - /** Set while resolving a diamond press so the ancestor clip's onClick (which - * toggles selection off when already selected) ignores the native "click" - * the browser auto-synthesizes after this button's pointerdown+pointerup. */ - suppressClickRef?: React.RefObject; +/** A clip-% is a float division, so a raw tooltip reads `25.032499999999995%`. */ +function roundPct(percentage: number): number { + return Math.round(percentage * 1000) / 1000; } -const DIAMOND_RATIO = 0.8; -// Percentage tolerance for rendering keyframes near clip boundaries. Keyframes -// slightly outside [0, 100] (from rounding or stale cache during the async -// persist → reload cycle) are still rendered (the clip is overflow-visible) at -// their true position rather than hidden. -const KF_MIN_PCT = -5; -const KF_MAX_PCT = 105; - -type DragState = { - kfKey: string; - startX: number; - fromClipPct: number; - moved: boolean; -}; - -export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ +export const TimelineDiamondLane = memo(function TimelineDiamondLane({ keyframesData, clipWidthPx, clipHeightPx, @@ -80,14 +48,72 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ onShiftClickKeyframe, onContextMenuKeyframe, onMoveKeyframe, + onSelectSegment, suppressClickRef, -}: TimelineClipDiamondsProps) { + groupAware = false, + globalEase = "none", +}: TimelineDiamondLaneProps) { // Hooks must run before the early return below. const dragRef = useRef(null); + // Pending retime destination (clip + tween %) per keyframe key, so a rapid + // second drag composes from where the first move left the keyframe (whose + // cache entry has not rebuilt yet) instead of the stale rendered value. + const pendingRetimeRef = useRef | null>(null); + // Lazy: `useRef(new Map())` allocates a Map on every render and throws all but + // the first away, once per mounted lane. + pendingRetimeRef.current ??= new Map(); + const pendingRetimes = pendingRetimeRef.current; + // The most recent retime dispatched from this lane, whichever diamond it came + // from. Selection is lane-wide, so "is my revert still relevant" is a lane-wide + // question, not a per-keyframe one. + const latestRetimeRef = useRef<{ clipPct: number; tweenPct: number } | null>(null); + useEffect(() => { + // Clear a pending entry once the authoritative cache reflects THAT keyframe + // at ~its destination. Match by tolerance, not equality: cache writers round + // clip %s, so an exact check would leak an entry after every successful + // retime. Match by identity too: a bare "some keyframe is near that %" test + // cleared the entry whenever an unrelated sibling happened to sit there, + // which is easy to hit on an evenly spaced row. + const pendingEntries = pendingRetimeRef.current; + if (!pendingEntries) return; + for (const [key, pending] of pendingEntries) { + const settled = keyframesData.keyframes.some( + (k) => + timelineKeyframeSelectionKey(elementId, keyframeTarget(k)) === key && + Math.abs(k.percentage - pending.clipPct) < 0.2, + ); + if (settled) pendingEntries.delete(key); + } + }, [keyframesData.keyframes, elementId]); // Visual-only preview of the dragged diamond's clip-% — no runtime/GSAP hold // (that optimistic hold was the #1763 flake). The atomic move-keyframe commit // on drop re-keys the diamond from source. const [preview, setPreview] = useState<{ kfKey: string; clipPct: number } | null>(null); + // One preview render per frame: a 120Hz trackpad fires pointermove far faster + // than the lane can repaint, and every diamond in the row re-evaluates its + // memo on each of those renders. + const previewFrameRef = useRef(null); + const cancelPreviewFrame = () => { + if (previewFrameRef.current === null) return; + cancelAnimationFrame(previewFrameRef.current); + previewFrameRef.current = null; + }; + // Escape backs out of an in-flight retime, the way clip and element drags + // already do. Nothing was written yet (the commit happens on pointerup), so + // dropping the preview is the whole undo. + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || !dragRef.current || dragRef.current.cancelled) return; + dragRef.current.cancelled = true; + cancelPreviewFrame(); + setPreview(null); + }; + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("keydown", onKeyDown); + cancelPreviewFrame(); + }; + }, []); // The button element can re-render (reposition/unmount) synchronously from // the state updates onClickKeyframe/onMoveKeyframe trigger, before the // browser gets to auto-synthesize the "click" event that normally follows @@ -108,15 +134,67 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ // When the beat strip occupies the top band, shrink the diamonds and center // them in the remaining bottom region so they don't collide with it. - const diamondSize = Math.round(clipHeightPx * (beatsActive ? 0.45 : DIAMOND_RATIO)); - const half = diamondSize / 2; + // One consistent keyframe-diamond size everywhere (clip bars + property lanes), + // matching the property-lane size (LANE_H · ratio). Beat-strip tracks still + // shrink to fit under the strip. + const diamondSize = beatsActive + ? Math.round(clipHeightPx * 0.45) + : Math.round(LANE_H * DIAMOND_RATIO); const centerY = beatsActive ? BEAT_BAND_H + (clipHeightPx - BEAT_BAND_H) / 2 : clipHeightPx / 2; const sorted = keyframesData.keyframes .filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT) .sort((a, b) => a.percentage - b.percentage); - // Clip-%s of the sorted keyframes — the neighbour clamp (preview + drop) needs - // the whole row to bound the dragged diamond between its immediate siblings. - const sortedClipPcts = sorted.map((k) => k.percentage); + // The neighbour clamp bounds a dragged diamond between its immediate siblings + // so a retime can't reorder the tween. Siblings means "keyframes of the SAME + // tween": a merged row interleaves several animations, and two of them + // colliding at one percentage would otherwise pin each other's diamonds in + // place — the drag clamped back onto its own position and resolved to a click. + const siblingRowOf = (keyframe: TimelineDiamondKeyframe) => + keyframe.animationId === undefined + ? sorted + : sorted.filter((k) => k.animationId === keyframe.animationId); + // Compose each sibling's pending destination in first: clamping against + // cached positions while the dragged keyframe reads its pending one let a + // second drag cross a neighbour that had already moved past it. Built once + // per render, keyed by tween: every diamond of a row needs the same row, and + // rebuilding + re-sorting it inside the marker loop below made this + // O(keyframes squared) allocations on every playhead tick. + const pendingClipPctOf = (keyframe: TimelineDiamondKeyframe) => + pendingRetimes.get(timelineKeyframeSelectionKey(elementId, keyframeTarget(keyframe))) + ?.clipPct ?? keyframe.percentage; + const siblingRows = new Map< + string | undefined, + { keyframes: TimelineDiamondKeyframe[]; clipPcts: number[] } + >(); + for (const keyframe of sorted) { + if (siblingRows.has(keyframe.animationId)) continue; + const row = siblingRowOf(keyframe) + .map((k) => ({ keyframe: k, clipPct: pendingClipPctOf(k) })) + .sort((a, b) => a.clipPct - b.clipPct); + siblingRows.set(keyframe.animationId, { + keyframes: row.map((s) => s.keyframe), + clipPcts: row.map((s) => s.clipPct), + }); + } + const centerXOf = (percentage: number) => + Math.max(0, Math.min(clipWidthPx, (percentage / 100) * clipWidthPx)); + // One record per diamond, carrying its own geometry, so the connector and + // button passes below read neighbours as values instead of index lookups. + const markers = sorted.map((keyframe, index) => { + const centerX = centerXOf(keyframe.percentage); + const previous = sorted[index - 1]; + const next = sorted[index + 1]; + const previousGap = previous ? centerX - centerXOf(previous.percentage) : Infinity; + const nextGap = next ? centerXOf(next.percentage) - centerX : Infinity; + const nearestGap = Math.max(1, Math.min(previousGap, nextGap)); + const gapWidth = Math.min(diamondSize, nearestGap); + return { + keyframe, + centerX, + hitWidth: Math.max(KF_MIN_HIT_W, gapWidth), + visualSize: gapWidth === diamondSize ? diamondSize : Math.max(2, gapWidth - 2), + }; + }); const baseColor = isSelected ? accentColor : "#a3a3a3"; const baseOpacity = isSelected ? 0.4 : 0.25; const canDrag = isSelected && !!onMoveKeyframe; @@ -135,39 +213,33 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ pointerEvents: "none", }} > - {sorted.map((kf, i) => { - if (i === 0) return null; - const prev = sorted[i - 1]!; - const x1 = Math.max(0, Math.min(clipWidthPx, (prev.percentage / 100) * clipWidthPx)); - const x2 = Math.max(0, Math.min(clipWidthPx, (kf.percentage / 100) * clipWidthPx)); - if (x2 - x1 < 1) return null; - return ( -
- ); - })} + - {sorted.map((kf, i) => { - const kfKey = `${elementId}:${kf.percentage}`; + {markers.map((marker, i) => { + const kf = marker.keyframe; + const target = keyframeTarget(kf); + const kfKey = timelineKeyframeSelectionKey(elementId, target); + // Clamp against this keyframe's own tween, not the whole merged row. + const siblingRow = siblingRows.get(kf.animationId); + const siblingClipPcts = siblingRow?.clipPcts ?? []; + const siblingIndex = siblingRow?.keyframes.indexOf(kf) ?? -1; // While dragging this diamond, render it at the live preview clip-%. const renderPct = preview?.kfKey === kfKey ? preview.clipPct : kf.percentage; - // Center the diamond ON its keyframe %: left = (% · width) − half so the - // diamond's midpoint sits exactly at the percentage. At 0% the midpoint - // is the clip's left edge (the left half overflows, which the - // overflow-visible clip shows) — NOT shifted fully inside. - const leftPx = (renderPct / 100) * clipWidthPx - half; + // Center the marker's non-overlapping hit region ON its keyframe %, so + // the diamond's midpoint sits exactly on the playhead/ruler x for that time. + // The 0% diamond's left half lands in the reserved left gutter (the + // content origin is inset past the label column, Figma-style) so it stays + // fully visible instead of being clipped by the sticky label column. + const leftPx = (renderPct / 100) * clipWidthPx - marker.hitWidth / 2; const isKfSelected = selectedKeyframes.has(kfKey); const atPlayhead = isSelected && Math.abs(kf.percentage - currentPercentage) < 0.5; const isHighlighted = isKfSelected || atPlayhead; @@ -181,53 +253,72 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ dragRef.current = { kfKey, startX: e.clientX, - fromClipPct: kf.percentage, + lastX: e.clientX, + index: siblingIndex, + fromClipPct: pendingRetimes.get(kfKey)?.clipPct ?? kf.percentage, moved: false, }; } }; const onPointerMove = (e: React.PointerEvent) => { const d = dragRef.current; - if (!d || d.kfKey !== kfKey) return; + if (!d || d.kfKey !== kfKey || d.cancelled) return; + d.lastX = e.clientX; if (!d.moved && Math.abs(e.clientX - d.startX) >= KEYFRAME_DRAG_THRESHOLD_PX) { d.moved = true; } - if (d.moved) { + if (!d.moved || previewFrameRef.current !== null) return; + previewFrameRef.current = requestAnimationFrame(() => { + previewFrameRef.current = null; + const live = dragRef.current; + if (!live || live.kfKey !== kfKey || live.cancelled) return; setPreview({ kfKey, clipPct: previewClipPct({ - pointerDownX: d.startX, - pointerMoveX: e.clientX, + pointerDownX: live.startX, + pointerMoveX: live.lastX, clipWidthPx, - draggedClipPct: d.fromClipPct, - draggedIndex: i, - sortedClipPcts, + draggedClipPct: live.fromClipPct, + draggedIndex: live.index, + sortedClipPcts: siblingClipPcts, }), }); - } + }); }; const onPointerUp = (e: React.PointerEvent) => { const d = dragRef.current; + if (d?.kfKey === kfKey && d.cancelled) { + // Escape already ended this drag; the release is not a click. + dragRef.current = null; + e.currentTarget.releasePointerCapture?.(e.pointerId); + suppressNextClick(); + return; + } // No drag armed (canDrag false / non-primary press) → treat as a click. if (!d || d.kfKey !== kfKey) { if (e.button !== 0) return; suppressNextClick(); - if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage); - else onClickKeyframe?.(kf.percentage); + if (e.shiftKey) onShiftClickKeyframe?.(target); + else onClickKeyframe?.(target); return; } e.stopPropagation(); dragRef.current = null; + cancelPreviewFrame(); setPreview(null); e.currentTarget.releasePointerCapture?.(e.pointerId); suppressNextClick(); + // Single-diamond retime by design: a multi-select drag would have to + // move every selected keyframe as one mutation, which the script ops + // do not express yet. Selecting several and dragging one moves only + // the dragged one. const res = resolveKeyframeDrag({ pointerDownX: d.startX, pointerUpX: e.clientX, clipWidthPx, draggedClipPct: d.fromClipPct, - draggedIndex: i, - sortedClipPcts, + draggedIndex: siblingIndex, + sortedClipPcts: siblingClipPcts, }); if (res.kind === "click" || res.kind === "noop") { // "noop" is a press with enough pointer jitter to arm a drag (canDrag @@ -235,27 +326,92 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ // back onto ~the same position — no real retime, so treat it as the // click it was. Otherwise a normal click with a few px of mouse/ // trackpad drift silently does nothing: no selection, no move. - if (e.shiftKey) onShiftClickKeyframe?.(elementId, kf.percentage); - else onClickKeyframe?.(kf.percentage); + if (e.shiftKey) onShiftClickKeyframe?.(target); + else onClickKeyframe?.(target); } else if (res.kind === "move" && res.toClipPct != null) { - onMoveKeyframe?.(elementId, d.fromClipPct, res.toClipPct); + const animKfs = + target.animationId === undefined + ? keyframesData.keyframes + : keyframesData.keyframes.filter((k) => k.animationId === target.animationId); + // Clamp to the mapped tween range: clipToTweenPercentage extrapolates + // linearly, so a boundary drag past the range would otherwise reselect + // an out-of-range tween % (e.g. 150%) even though the mutation clamps + // the moved endpoint back to the boundary. + const tweenPcts = animKfs + .map((k) => k.tweenPercentage) + .filter((v): v is number => typeof v === "number"); + const clampTween = (v: number) => + tweenPcts.length + ? Math.max(Math.min(...tweenPcts), Math.min(Math.max(...tweenPcts), v)) + : v; + const newTweenPct = clampTween(clipToTweenPercentage(animKfs, res.toClipPct)); + // For a rapid second retime the diamond still renders the stale cache + // position, so identify the FROM keyframe by the pending (already-moved) + // position; the mutation locates the source keyframe by this identity. + const pendingBefore = pendingRetimes.get(kfKey); + const fromTarget = pendingBefore + ? { + ...target, + percentage: pendingBefore.clipPct, + tweenPercentage: pendingBefore.tweenPct, + } + : target; + const pending = { clipPct: res.toClipPct, tweenPct: newTweenPct }; + pendingRetimes.set(kfKey, pending); + latestRetimeRef.current = pending; + const clearPending = () => { + if (pendingRetimes.get(kfKey) === pending) { + pendingRetimes.delete(kfKey); + } + }; + // A rejected drop (the destination time is already occupied) snaps + // the diamond back to its source position, so the pending entry AND + // the selection have to revert with it — parking on the ghost drop + // position strands the playhead + selection on a keyframe that does + // not exist there. + const revertRetime = () => { + // Only the newest gesture owns the selection. A rejected first drag + // whose commit settles after a second one started would otherwise + // park the selection back on ITS source keyframe, undoing a retime + // the user has already made and moving the playhead with it. + const isLatest = latestRetimeRef.current === pending; + clearPending(); + if (isLatest) onClickKeyframe?.(fromTarget); + }; + void onMoveKeyframe?.(fromTarget, res.toClipPct).then((committed) => { + if (!committed) revertRetime(); + }, revertRetime); // A retime still targeted this exact diamond — park/select it at its // new position, same as a plain click, or a drag that actually moved - // something looks identical to one that silently did nothing. - onClickKeyframe?.(res.toClipPct); + // something looks identical to one that silently did nothing. Done + // optimistically so the gesture stays responsive; revertRetime puts + // it back if the move is rejected. + onClickKeyframe?.({ + ...target, + percentage: res.toClipPct, + tweenPercentage: newTweenPct, + }); } }; return ( +
+ )} + + ); + })} + + ); +} diff --git a/packages/studio/src/player/components/TimelineDragGhost.tsx b/packages/studio/src/player/components/TimelineDragGhost.tsx deleted file mode 100644 index 771bddb65d..0000000000 --- a/packages/studio/src/player/components/TimelineDragGhost.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import type { ReactNode } from "react"; -import { TimelineClip } from "./TimelineClip"; -import { getTimelineEditCapabilities } from "./timelineEditing"; -import { CLIP_Y, TRACK_H } from "./timelineLayout"; -import type { TimelineTheme } from "./timelineTheme"; -import type { TimelineElement } from "../store/playerStore"; - -interface TimelineDragGhostProps { - element: TimelineElement; - position: { left: number; top: number }; - pps: number; - selectedElementId: string | null; - hasCustomContent: boolean; - theme: TimelineTheme; - children: ReactNode; -} - -export function TimelineDragGhost({ - element, - position, - pps, - selectedElementId, - hasCustomContent, - theme, - children, -}: TimelineDragGhostProps) { - return ( -
- {}} - onHoverEnd={() => {}} - onResizeStart={() => {}} - onClick={() => {}} - onDoubleClick={() => {}} - > - {children} - -
- ); -} diff --git a/packages/studio/src/player/components/TimelineLanes.tsx b/packages/studio/src/player/components/TimelineLanes.tsx index 75069c1a0b..d8b622f2ca 100644 --- a/packages/studio/src/player/components/TimelineLanes.tsx +++ b/packages/studio/src/player/components/TimelineLanes.tsx @@ -1,12 +1,16 @@ import { type ReactNode } from "react"; -import { Eye, EyeSlash } from "@phosphor-icons/react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { BeatStrip, BeatBackgroundLines } from "./BeatStrip"; import { TimelineClip } from "./TimelineClip"; import { TimelineClipDiamonds } from "./TimelineClipDiamonds"; +import { TimelinePropertyLanes } from "./TimelinePropertyLanes"; +import { TimelineTrackHeader } from "./TimelineTrackHeader"; +import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout"; +import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity"; import type { MusicBeatAnalysis } from "@hyperframes/core/beats"; import { getTimelineEditCapabilities, resolveBlockedTimelineEditIntent } from "./timelineEditing"; import type { TimelineTheme } from "./timelineTheme"; -import { GUTTER, TRACK_H, TRACKS_LEFT_PAD, CLIP_Y, CLIP_HANDLE_W } from "./timelineLayout"; +import { CLIP_Y, CLIP_HANDLE_W, TRACK_H, getTimelineRowHeight } from "./timelineLayout"; import { usePlayerStore, type TimelineElement, @@ -21,9 +25,9 @@ import { import type { TrackVisualStyle } from "./timelineIcons"; import type { TimelineEditCallbacks } from "./timelineCallbacks"; import { STUDIO_KEYFRAMES_ENABLED } from "../../components/editor/manualEditingAvailability"; +import { trackStudioKeyframeLaneExpand } from "../../telemetry/events"; import { SPLIT_BOUNDARY_EPSILON_S } from "../../utils/timelineElementSplit"; import { isAudioTimelineElement, isMusicTrack } from "../../utils/timelineInspector"; -import { Music } from "../../icons/SystemIcons"; import { renderClipChildren } from "./timelineClipChildren"; /** @@ -34,12 +38,16 @@ import { renderClipChildren } from "./timelineClipChildren"; */ export interface TimelineLaneBaseProps { pps: number; + contentOrigin: number; + contentGutter: number; trackContentWidth: number; theme: TimelineTheme; displayTrackOrder: number[]; + rowHeights: readonly number[]; trackOrder: number[]; tracks: [number, TimelineElement[]][]; trackStyles: Map; + laneCounts: ReadonlyMap; selectedElementId: string | null; selectedElementIds: Set; hoveredClip: string | null; @@ -69,16 +77,26 @@ export interface TimelineLaneBaseProps { getPreviewElement: (element: TimelineElement) => TimelineElement; getTrackStyle: (tag: string) => TrackVisualStyle; keyframeCache?: Map; + gsapAnimations: Map; selectedKeyframes: Set; currentTime: number; - onClickKeyframe?: (element: TimelineElement, percentage: number) => void; - onShiftClickKeyframe?: (elementId: string, percentage: number) => void; - onContextMenuKeyframe?: (e: React.MouseEvent, elementId: string, percentage: number) => void; + onSeek?: (time: number) => void; + onSelectSegment?: (elementId: string, target: TimelineKeyframeTarget) => void; + onClickKeyframe?: (element: TimelineElement, target: TimelineKeyframeTarget) => void; + onShiftClickKeyframe?: (elementId: string, target: TimelineKeyframeTarget) => void; + onContextMenuKeyframe?: ( + e: React.MouseEvent, + elementId: string, + target: TimelineKeyframeTarget, + ) => void; onMoveKeyframe?: ( elementId: string, - fromClipPercentage: number, + keyframe: TimelineKeyframeTarget, toClipPercentage: number, - ) => void; + propertyGroup?: string, + tweenPercentage?: number, + animationId?: string, + ) => Promise; onContextMenuClip?: (e: React.MouseEvent, element: TimelineElement) => void; /** * Right-click on EMPTY lane space (not on a clip — those preventDefault @@ -94,6 +112,7 @@ interface TimelineLanesProps extends TimelineLaneBaseProps { draggedElement: TimelineElement | null; multiDragPreview: MultiDragPreviewInput | null; onToggleTrackHidden: TimelineEditCallbacks["onToggleTrackHidden"]; + onTogglePropertyGroupKeyframe: TimelineEditCallbacks["onTogglePropertyGroupKeyframe"]; onResizeElement: TimelineEditCallbacks["onResizeElement"]; onMoveElement: TimelineEditCallbacks["onMoveElement"]; onRazorSplit: TimelineEditCallbacks["onRazorSplit"]; @@ -102,12 +121,16 @@ interface TimelineLanesProps extends TimelineLaneBaseProps { export function TimelineLanes({ pps, + contentOrigin, + contentGutter, trackContentWidth, theme, displayTrackOrder, + rowHeights, trackOrder, tracks, trackStyles, + laneCounts, selectedElementId, selectedElementIds, hoveredClip, @@ -132,8 +155,11 @@ export function TimelineLanes({ getPreviewElement, getTrackStyle, keyframeCache, + gsapAnimations, selectedKeyframes, currentTime, + onSeek, + onSelectSegment, onClickKeyframe, onShiftClickKeyframe, onContextMenuKeyframe, @@ -142,11 +168,19 @@ export function TimelineLanes({ onContextMenuLane, beatAnalysis, onToggleTrackHidden, + onTogglePropertyGroupKeyframe, onResizeElement, onMoveElement, onRazorSplit, onRazorSplitAll, }: TimelineLanesProps) { + const expandedClipIds = usePlayerStore((s) => s.expandedClipIds); + const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded); + const toggleClipExpandedTracked = (key: string) => { + const willExpand = !expandedClipIds.has(key); + trackStudioKeyframeLaneExpand({ expanded: willExpand }); + toggleClipExpanded(key); + }; return ( <> { @@ -156,7 +190,8 @@ export function TimelineLanes({ // bounded and virtualization's complexity isn't worth it. TODO: revisit and swap // in a virtualizer if editorial workflows ever push very high clip counts. // fallow-ignore-next-line complexity - displayTrackOrder.map((trackNum) => { + displayTrackOrder.map((trackNum, row) => { + const rowHeight = getTimelineRowHeight(row, rowHeights); const els = tracks.find(([t]) => t === trackNum)?.[1] ?? []; const ts = trackStyles.get(trackNum) ?? getTrackStyle(""); const isPendingTrack = @@ -173,58 +208,50 @@ export function TimelineLanes({ : els.some(isMusicTrack)); const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true); const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement); + // The one keyframed element this track shows lanes for (selected, else + // most lanes). A track can hold several elements; scoping to one keeps + // their keyframes from cramming into a single row. + const keyframeClip = STUDIO_KEYFRAMES_ENABLED + ? resolveTrackKeyframeClip(els, laneCounts, selectedElementId, selectedElementIds) + : null; + const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id; + const keyframeClipExpanded = + keyframeClipKey != null && expandedClipIds.has(keyframeClipKey); return ( -
-
+ { + if (keyframeClipKey) { + toggleClipExpandedTracked(keyframeClipKey); + } }} - > - {isAudioTrack && ( -
- {/* Left breathing pad — empty lane surface before t=0, scrolling - with the content (the horizontal TRACKS_TOP_PAD). Sits OUTSIDE - the time-mapped content div so clip/beat/menu math stays - content-relative (clip left = t·pps). */} -