From 280b2fe1e3d3681c95f77a11ceff657f12d9cf3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 7 Jun 2026 20:17:01 -0400 Subject: [PATCH 1/2] feat(studio): gesture-to-keyframes recording Gesture recording engine with RAF-based sampling, RDP simplification, velocity-to-ease inference, ghost trail overlay, post-record preview panel, and keyframe commit pipeline. Record button in animation section, R keyboard shortcut, clipboard element context copy with toast, glass toast styling, always-visible render queue actions, and keyframe diamond dedup fix. --- docs/docs.json | 1 + docs/guides/keyframes.mdx | 141 ++++++++++ packages/core/src/parsers/gsapParser.ts | 21 +- packages/core/src/runtime/init.ts | 15 -- packages/studio/src/App.tsx | 98 ++++++- .../studio/src/components/StudioHeader.tsx | 2 + .../src/components/StudioRightPanel.tsx | 9 + .../studio/src/components/StudioToast.tsx | 54 +++- .../studio/src/components/TimelineToolbar.tsx | 16 ++ .../components/editor/GesturePreviewPanel.tsx | 147 +++++++++++ .../components/editor/GestureTrailOverlay.tsx | 118 +++++++++ .../src/components/editor/PropertyPanel.tsx | 118 ++++++++- .../components/editor/propertyPanelHelpers.ts | 3 + .../editor/propertyPanelStyleSections.tsx | 10 +- .../components/renders/RenderQueueItem.tsx | 93 +++---- .../studio/src/hooks/gsapRuntimeBridge.ts | 67 +---- .../studio/src/hooks/gsapRuntimeKeyframes.ts | 6 + packages/studio/src/hooks/useAppHotkeys.ts | 18 ++ .../studio/src/hooks/useDomEditCommits.ts | 10 +- packages/studio/src/hooks/useGestureCommit.ts | 56 ++++ .../studio/src/hooks/useGestureRecording.ts | 245 ++++++++++++++++++ .../studio/src/hooks/useGsapScriptCommits.ts | 8 +- packages/studio/src/hooks/useToast.ts | 7 +- .../components/TimelineClipDiamonds.tsx | 6 +- packages/studio/src/utils/inferEase.ts | 202 +++++++++++++++ packages/studio/src/utils/rdpSimplify.ts | 183 +++++++++++++ 26 files changed, 1487 insertions(+), 167 deletions(-) create mode 100644 docs/guides/keyframes.mdx create mode 100644 packages/studio/src/components/editor/GesturePreviewPanel.tsx create mode 100644 packages/studio/src/components/editor/GestureTrailOverlay.tsx create mode 100644 packages/studio/src/hooks/useGestureCommit.ts create mode 100644 packages/studio/src/hooks/useGestureRecording.ts create mode 100644 packages/studio/src/utils/inferEase.ts create mode 100644 packages/studio/src/utils/rdpSimplify.ts diff --git a/docs/docs.json b/docs/docs.json index 3982833c1..c8a1caac6 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -84,6 +84,7 @@ "guides/prompting", "guides/hyperframes-vs-remotion", "guides/gsap-animation", + "guides/keyframes", "guides/rendering", "guides/remove-background", "guides/hdr", diff --git a/docs/guides/keyframes.mdx b/docs/guides/keyframes.mdx new file mode 100644 index 000000000..74be00597 --- /dev/null +++ b/docs/guides/keyframes.mdx @@ -0,0 +1,141 @@ +--- +title: Keyframes & Arc Motion +description: "Edit GSAP keyframes visually in Studio — timeline diamonds, arc motion paths, and gesture recording." +--- + +Studio gives you visual tools to create and edit GSAP keyframes without writing code. You can adjust animation properties in the Design Panel, convert straight-line motion into curved arcs, and record gesture-based motion by dragging elements in the preview. + +## Timeline Keyframe Diamonds + +When you open a composition in Studio, the timeline shows **diamond markers** on clips that have GSAP animations. Each diamond represents a keyframe — a point in time where a property value is set. + +- **Start diamond** — where the tween begins (e.g., `x: 0`) +- **End diamond** — where the tween ends (e.g., `x: 1000`) +- Elements with multiple tweens show multiple diamond pairs + + + Keyframe diamonds are synthesized from your GSAP tweens automatically. Every `.to()`, `.from()`, and `.fromTo()` call produces start and end markers on the timeline. + + +## Editing Animation Properties + +Select any animated element in the preview or timeline to open the Design Panel. The **Animation** section shows: + +- **Method badge** — `Animate`, `Animate In`, or `Animate Out` (maps to `.to()`, `.from()`, `.fromTo()`) +- **Timing** — Length (duration) and Starts at (position on timeline) +- **Speed** — The GSAP ease (e.g., `power2.inOut`, `back.out(3)`) +- **Speed curve** — Visual preview of the easing function +- **Properties** — Each animated property (Move X, Move Y, Scale, Opacity, etc.) with its target value + + + + Click an animated element in the preview or its clip in the timeline. The Design Panel opens on the right. + + + Change any property value directly — for example, set Move X to `500` to make the element travel 500px. Changes apply immediately via soft reload. + + + Click the ease dropdown (e.g., "Smooth ease") to pick a different easing function. The speed curve preview updates live. + + + Switch to the Code tab to see the generated GSAP code. Every Design Panel edit writes valid GSAP that renders identically in preview and headless export. + + + +## Arc Motion + +Arc Motion converts a straight-line x/y animation into a curved path using GSAP's MotionPathPlugin. Instead of moving in a straight diagonal, the element follows a smooth arc — like tossing an object into a basket. + +### When to Use It + +Use Arc Motion when an element has both `x` and `y` properties in a single tween. Common examples: +- Add-to-cart animations (item arcs from product to cart icon) +- Throw/toss effects +- Any motion that should feel physical rather than robotic + +### Step-by-Step + + + + The element must have a `.to()` tween with both Move X and Move Y properties. Select it in the preview or timeline. + + + In the Animation section of the Design Panel, find the **Arc Motion** toggle below the property list. Switch it ON. + + + The **Curviness** slider controls how exaggerated the arc is: + - `0` — straight line (no curve) + - `1` — gentle natural arc + - `1.5–2.0` — smooth throw feel (recommended) + - `3.0` — extreme loop + + Scrub the timeline to preview the arc in real time. + + + Enable **Auto-Rotate** to make the element rotate to face the direction of travel along the arc. This adds a "thrown" feel vs. a "floating" feel. + + + Switch to the Code tab. You'll see: + + ```javascript + tl.to("#element", { + scale: 0.4, + opacity: 0, + duration: 1.0, + ease: "power2.inOut", + motionPath: { + path: [{x: 0, y: 0}, {x: 1400, y: -280}], + curviness: 1.5, + autoRotate: true + } + }, 1.0); + ``` + + The MotionPathPlugin CDN script is added automatically. + + + Toggle Arc Motion OFF to restore the original `x` and `y` properties as flat tween values. + + + + + Arc Motion works for flat `.to()` tweens with x/y properties. It synthesizes waypoints from `{x: 0, y: 0}` (start) to `{x: targetX, y: targetY}` (end). For more complex paths with intermediate waypoints, edit the `motionPath.path` array directly in the Code tab. + + +## Gesture Recording + +Record motion by physically dragging an element in the preview while the timeline plays. The pointer path is simplified and converted into GSAP keyframes automatically. + + + + Click the element you want to animate in the preview. + + + In the Animation section of the Design Panel, click **Record gesture (R)** or press the R key. The timeline starts playing. + + + Move the element in the preview by dragging it. Your pointer motion is sampled at ~60fps. A trail overlay shows the path you're drawing. + + + Press R again or wait for the timeline to reach the end. Recording stops, the motion is simplified (reducing ~180 raw samples to 5–15 clean keyframes), and the keyframes are written to the GSAP script immediately. + + + The timeline seeks back to the recording start so you can scrub through the result. If you don't like it, press **Cmd+Z** to undo and try again. + + + +## Clipboard Context + +The **clipboard icon** next to the element name in the Design Panel copies structured element context to your clipboard: + +``` +Element: Title (#title) +File: index.html:15 +Position: x=100, y=40 +Size: 264×43 +Tag:
+Animation: from() 0.5s at 0s, ease: power2.out +Properties: x: -40, opacity: 0 +``` + +Paste this into any AI agent prompt to give it spatial context about the element — its position, size, animation, and source location. diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index 0a9888d32..8471cc925 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -1354,10 +1354,25 @@ export function addKeyframeToScript( ease?: string, backfillDefaults?: Record, ): string { - const loc = locateAnimation(script, animationId); + let loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } if (!loc) return script; - const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); - if (!kfNode) return script; + let kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + + if (!kfNode) { + script = convertToKeyframesInScript(script, animationId); + loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } + if (!loc) return script; + kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + if (!kfNode) return script; + } const pctKey = `${percentage}%`; const newValueNode = buildKeyframeValueNode(properties, ease); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index a4758d62f..530ede9f8 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1002,25 +1002,12 @@ export function initSandboxRuntimeModular(): void { }); // Stamp data-start / data-duration on GSAP-targeted elements that lack // them so the Studio timeline can discover individual animated elements. - // Skip elements whose ancestor already carries timing — stamping them - // would override the parent's clip visibility and cause preview/render - // parity drift. { const rootComp = resolveRootCompositionElement(); const rootDuration = boundDuration > 0 ? boundDuration : 0; const dur = String(rootDuration > 0 ? rootDuration : 1); const seen = new Set(); - const hasTimedAncestor = (el: HTMLElement): boolean => { - let cursor = el.parentElement; - while (cursor) { - if (cursor.hasAttribute("data-start")) return true; - if (cursor === rootComp) return false; - cursor = cursor.parentElement; - } - return false; - }; - // Stamp GSAP-targeted elements if (state.capturedTimeline.getChildren) { try { @@ -1030,7 +1017,6 @@ export function initSandboxRuntimeModular(): void { if (!(target instanceof HTMLElement)) continue; if (target === rootComp) continue; if (target.hasAttribute("data-start")) continue; - if (hasTimedAncestor(target)) continue; if (seen.has(target)) continue; seen.add(target); target.setAttribute("data-start", "0"); @@ -1050,7 +1036,6 @@ export function initSandboxRuntimeModular(): void { if (!(el instanceof HTMLElement)) continue; if (el === rootComp) continue; if (el.hasAttribute("data-start")) continue; - if (hasTimedAncestor(el)) continue; if (seen.has(el)) continue; if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue; seen.add(el); diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index af9edf131..480b1ca48 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -35,6 +35,9 @@ import type { DomEditSelection } from "./components/editor/domEditing"; import { AskAgentModal } from "./components/AskAgentModal"; import { StudioGlobalDragOverlay } from "./components/StudioGlobalDragOverlay"; import { StudioHeader } from "./components/StudioHeader"; +import { useGestureRecording } from "./hooks/useGestureRecording"; +import { simplifyGestureSamples } from "./utils/rdpSimplify"; +import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay"; import { StudioLeftSidebar } from "./components/StudioLeftSidebar"; import { StudioPreviewArea } from "./components/StudioPreviewArea"; import { StudioRightPanel } from "./components/StudioRightPanel"; @@ -128,7 +131,7 @@ export function StudioApp() { return !v; }); }, []); - const { appToast, showToast } = useToast(); + const { appToast, showToast, dismissToast } = useToast(); const panelLayout = usePanelLayout({ rightCollapsed: initialUrlStateRef.current.rightCollapsed, rightPanelTab: initialUrlStateRef.current.rightPanelTab, @@ -306,7 +309,9 @@ export function StudioApp() { onResetKeyframes: () => resetKeyframesRef.current(), onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(), onAfterUndoRedo: () => invalidateGsapCacheRef.current(), + onToggleRecording: () => handleToggleRecordingRef.current(), }); + const handleToggleRecordingRef = useRef(() => {}); const selectSidebarTabStable = useCallback( (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab), [], @@ -400,6 +405,76 @@ export function StudioApp() { const dragOverlay = useDragOverlay(fileManager.handleImportFiles); + // Gesture recording + const gestureRecording = useGestureRecording(); + const [gestureState, setGestureState] = useState<"idle" | "recording">("idle"); + const recordingAutoStopRef = useRef>(undefined); + const recordingStartTimeRef = useRef(0); + + const stopAndCommitRecording = useCallback(() => { + clearInterval(recordingAutoStopRef.current); + gestureRecording.stopRecording(); + const store = usePlayerStore.getState(); + store.setIsPlaying(false); + + const sel = domEditSession.domEditSelection; + const animId = domEditSession.selectedGsapAnimations?.[0]?.id; + const samples = gestureRecording.samples; + const duration = gestureRecording.recordingDuration; + + if (sel && animId && samples.length > 2 && duration > 0) { + const simplified = simplifyGestureSamples(samples, duration, 5); + const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b); + for (const pct of sortedPcts) { + const props = simplified.get(pct); + if (!props) continue; + for (const [prop, value] of Object.entries(props)) { + domEditSession.handleGsapAddKeyframe?.(animId, pct, prop, value); + } + } + showToast(`Recorded ${sortedPcts.length} keyframes`, "info"); + } + + store.requestSeek(recordingStartTimeRef.current); + gestureRecording.clearSamples(); + setGestureState("idle"); + }, [gestureRecording, domEditSession, showToast]); + + const handleToggleRecording = useCallback(() => { + if (gestureState === "recording") { + stopAndCommitRecording(); + return; + } + const sel = domEditSession.domEditSelection; + if (!sel) { + showToast("Select an element first", "error"); + return; + } + const iframe = previewIframeRef.current; + if (!iframe) return; + + const store = usePlayerStore.getState(); + recordingStartTimeRef.current = store.currentTime; + gestureRecording.startRecording(sel.element, iframe); + store.setIsPlaying(true); + setGestureState("recording"); + + clearInterval(recordingAutoStopRef.current); + recordingAutoStopRef.current = setInterval(() => { + const { currentTime: t, duration: d } = usePlayerStore.getState(); + if (d > 0 && t >= d - 0.05) { + stopAndCommitRecording(); + } + }, 100); + }, [ + gestureState, + gestureRecording, + domEditSession.domEditSelection, + showToast, + stopAndCommitRecording, + ]); + handleToggleRecordingRef.current = handleToggleRecording; + const handlePreviewIframeRef = useCallback( (iframe: HTMLIFrameElement | null) => { previewIframeRef.current = iframe; @@ -554,6 +629,9 @@ export function StudioApp() { setActiveBlockParams(null); panelLayout.setRightPanelTab("design"); }} + recordingState={gestureState} + recordingDuration={gestureRecording.recordingDuration} + onToggleRecording={handleToggleRecording} /> )}
@@ -585,8 +663,24 @@ export function StudioApp() { /> )} + {gestureState === "recording" && previewIframe && ( + { + const r = previewIframe.getBoundingClientRect(); + return { left: r.left, top: r.top, width: r.width, height: r.height }; + })()} + mode="recording" + /> + )} {dragOverlay.active && } - {appToast && } + {appToast && ( + + )} diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index 3404df15c..6cc5005b7 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -10,6 +10,8 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; import { useDomEditContext } from "../contexts/DomEditContext"; import { trackStudioEvent } from "../utils/studioTelemetry"; +export type RecordingState = "idle" | "recording" | "preview"; + export interface StudioHeaderProps { captureFrameHref: string; captureFrameFilename: string; diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index ed68072a8..f965802df 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -32,6 +32,9 @@ export interface StudioRightPanelProps { compositionPath: string; } | null; onCloseBlockParams?: () => void; + recordingState?: "idle" | "recording" | "preview"; + recordingDuration?: number; + onToggleRecording?: () => void; } // fallow-ignore-next-line complexity @@ -41,6 +44,9 @@ export function StudioRightPanel({ motionPanelActive, activeBlockParams, onCloseBlockParams, + recordingState, + recordingDuration, + onToggleRecording, }: StudioRightPanelProps) { const { rightWidth, @@ -230,6 +236,9 @@ export function StudioRightPanel({ onCommitAnimatedProperty={commitAnimatedProperty} onSetArcPath={handleSetArcPath} onUpdateArcSegment={handleUpdateArcSegment} + recordingState={recordingState} + recordingDuration={recordingDuration} + onToggleRecording={onToggleRecording} /> ) : motionPanelActive ? ( void; } -export function StudioToast({ message, tone }: StudioToastProps) { +export function StudioToast({ message, tone, onDismiss }: StudioToastProps) { + const isError = tone === "error"; return (
- {message} +
+ {message} + {onDismiss && ( + + )} +
); } diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 943c12bd4..a0f2fa84d 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -198,6 +198,22 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { flatAnim.id, Object.keys(runtimeProps).length > 0 ? runtimeProps : undefined, ); + const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; + const pct = + elDuration > 0 + ? Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10)) + : 0; + if (pct > 1 && pct < 99) { + const runtimeValues = readRuntimeKeyframeValues( + session.previewIframeRef?.current ?? null, + sel, + [], + ); + for (const [prop, val] of Object.entries(runtimeValues)) { + session.handleGsapAddKeyframe(flatAnim.id, pct, prop, val); + } + } } else { session.handleGsapAddAnimation("to"); } diff --git a/packages/studio/src/components/editor/GesturePreviewPanel.tsx b/packages/studio/src/components/editor/GesturePreviewPanel.tsx new file mode 100644 index 000000000..133e11a29 --- /dev/null +++ b/packages/studio/src/components/editor/GesturePreviewPanel.tsx @@ -0,0 +1,147 @@ +import { memo, useCallback, useMemo, useState } from "react"; +import type { GestureSample } from "../../hooks/useGestureRecording"; +import { simplifyGestureSamples } from "../../utils/rdpSimplify"; +import { inferAllEases } from "../../utils/inferEase"; +import { SliderControl } from "./propertyPanelPrimitives"; +import { LABEL } from "./propertyPanelHelpers"; +import { P } from "./panelTokens"; + +interface GesturePreviewPanelProps { + samples: GestureSample[]; + totalDuration: number; + onCommit: (keyframes: Map>, eases: Map) => void; + onDiscard: () => void; + onReRecord: () => void; + onSimplifiedChange?: (simplified: Map>) => void; +} + +export const GesturePreviewPanel = memo(function GesturePreviewPanel({ + samples, + totalDuration, + onCommit, + onDiscard, + onReRecord, + onSimplifiedChange, +}: GesturePreviewPanelProps) { + const [epsilon, setEpsilon] = useState(5); + + const simplified = useMemo(() => { + const result = simplifyGestureSamples(samples, totalDuration, epsilon); + onSimplifiedChange?.(result); + return result; + }, [samples, totalDuration, epsilon, onSimplifiedChange]); + + const eases = useMemo(() => { + const pcts = Array.from(simplified.keys()).sort((a, b) => a - b); + return inferAllEases( + samples.map((s) => ({ + time: s.time, + properties: s.properties, + })), + pcts, + totalDuration, + ); + }, [samples, simplified, totalDuration]); + + const keyframeCount = simplified.size; + const propertyList = useMemo(() => { + const keys = new Set(); + for (const props of simplified.values()) { + for (const k of Object.keys(props)) keys.add(k); + } + return Array.from(keys); + }, [simplified]); + + const handleEpsilonChange = useCallback((v: number) => { + setEpsilon(Math.round(v * 10) / 10); + }, []); + + const handleCommit = useCallback(() => { + onCommit(simplified, eases); + }, [onCommit, simplified, eases]); + + return ( +
+
+ Gesture Recording + {keyframeCount} keyframes +
+ +
+ Smoothing + v.toFixed(1)} + onCommit={handleEpsilonChange} + /> +
+ Detailed + Smooth +
+
+ + {propertyList.length > 0 && ( +
+ Recorded properties +
+ {propertyList.map((prop) => ( + + {prop} + + ))} +
+
+ )} + + {eases.size > 0 && ( +
+ Inferred eases +
+ {Array.from(eases.entries()) + .sort(([a], [b]) => a - b) + .map(([pct, ease]) => ( + + {pct}% → {ease} + + ))} +
+
+ )} + +
+ + + +
+
+ ); +}); diff --git a/packages/studio/src/components/editor/GestureTrailOverlay.tsx b/packages/studio/src/components/editor/GestureTrailOverlay.tsx new file mode 100644 index 000000000..a2a6abefc --- /dev/null +++ b/packages/studio/src/components/editor/GestureTrailOverlay.tsx @@ -0,0 +1,118 @@ +import { memo, useMemo } from "react"; +import type { GestureSample } from "../../hooks/useGestureRecording"; + +interface GestureTrailOverlayProps { + samples: GestureSample[]; + simplifiedPoints?: Map>; + canvasRect: { left: number; top: number; width: number; height: number }; + mode: "recording" | "preview"; + accentColor?: string; +} + +export const GestureTrailOverlay = memo(function GestureTrailOverlay({ + samples, + simplifiedPoints, + canvasRect, + mode, + accentColor = "#3CE6AC", +}: GestureTrailOverlayProps) { + const trailPoints = useMemo(() => { + if (samples.length === 0) return ""; + return samples + .filter((s) => s.properties.x != null && s.properties.y != null) + .map((s) => `${s.properties.x},${s.properties.y}`) + .join(" "); + }, [samples]); + + const simplifiedPath = useMemo(() => { + if (!simplifiedPoints || simplifiedPoints.size === 0) return ""; + const pts: Array<{ x: number; y: number; pct: number }> = []; + for (const [pct, props] of simplifiedPoints) { + if (props.x != null && props.y != null) { + pts.push({ x: props.x, y: props.y, pct }); + } + } + pts.sort((a, b) => a.pct - b.pct); + if (pts.length === 0) return ""; + return pts.map((p) => `${p.x},${p.y}`).join(" "); + }, [simplifiedPoints]); + + const diamondPositions = useMemo(() => { + if (!simplifiedPoints || simplifiedPoints.size === 0) return []; + const pts: Array<{ x: number; y: number; pct: number }> = []; + for (const [pct, props] of simplifiedPoints) { + if (props.x != null && props.y != null) { + pts.push({ x: props.x, y: props.y, pct }); + } + } + return pts.sort((a, b) => a.pct - b.pct); + }, [simplifiedPoints]); + + if (samples.length < 2 && !simplifiedPoints) return null; + + return ( + + {mode === "recording" && trailPoints && ( + + )} + + {mode === "preview" && ( + <> + {trailPoints && ( + + )} + {simplifiedPath && ( + + )} + {diamondPositions.map((pt) => ( + + + + ))} + + )} + + ); +}); diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index a95996e39..21290390f 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -1,5 +1,6 @@ -import { memo } from "react"; -import { Eye, Layers, MessageSquare, Move, X } from "../../icons/SystemIcons"; +import { memo, useRef, useState } from "react"; +import { Eye, Layers, Move, X } from "../../icons/SystemIcons"; +import { useStudioContext } from "../../contexts/StudioContext"; import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits"; import { EMPTY_STYLES, @@ -40,7 +41,7 @@ export const PropertyPanel = memo(function PropertyPanel({ assets, element, multiSelectCount = 0, - copiedAgentPrompt, + copiedAgentPrompt: _copiedAgentPrompt, onClearSelection, onSetStyle, onSetAttribute, @@ -52,7 +53,7 @@ export const PropertyPanel = memo(function PropertyPanel({ onSetTextFieldStyle, onAddTextField, onRemoveTextField, - onAskAgent, + onAskAgent: _onAskAgent, onImportAssets, fontAssets = [], onImportFonts, @@ -76,8 +77,15 @@ export const PropertyPanel = memo(function PropertyPanel({ onConvertToKeyframes, onCommitAnimatedProperty, onSeekToTime, + recordingState, + recordingDuration, + onToggleRecording, }: PropertyPanelProps) { const styles = element?.computedStyles ?? EMPTY_STYLES; + const { showToast } = useStudioContext(); + const [clipboardCopied, setClipboardCopied] = useState(false); + const clipboardTimerRef = useRef>(undefined); + const currentTime = usePlayerStore((s) => s.currentTime); if (!element) { return ( @@ -171,10 +179,8 @@ export const PropertyPanel = memo(function PropertyPanel({ onSetManualRotation(element, { angle: parsed }); }; - // Keyframe navigation state const elStart = Number.parseFloat(element?.dataAttributes?.start ?? "0") || 0; const elDuration = Number.parseFloat(element?.dataAttributes?.duration ?? "1") || 0; - const currentTime = usePlayerStore((s) => s.currentTime); const currentPct = elDuration > 0 ? ((currentTime - elStart) / elDuration) * 100 : 0; const gsapKeyframes = gsapAnimations?.find((a) => a.keyframes)?.keyframes?.keyframes ?? null; @@ -265,11 +271,78 @@ export const PropertyPanel = memo(function PropertyPanel({
+
+ )} + {showEditableSections && ( Promise; onSeekToTime?: (time: number) => void; + recordingState?: "idle" | "recording" | "preview"; + recordingDuration?: number; + onToggleRecording?: () => void; } /* ------------------------------------------------------------------ */ diff --git a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx index a755824d5..c955c03d6 100644 --- a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx @@ -369,15 +369,7 @@ export function StyleSections({ -
} - accessory={ -
- {preferredFillMode} -
- } - > +
}>
- {/* Actions */} - {hovered && ( -
- {isComplete && ( - - )} - -
- )} + + + + + + +
); diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 6e641dfc8..90db5ef56 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -261,13 +261,9 @@ async function commitGsapPositionFromDrag( callbacks, clearOffset, ); - } else if (anim.method === "from") { - await commitFromPosition(selection, anim, studioOffset, callbacks, clearOffset); - } else if (anim.method === "fromTo") { - await commitFromToPosition(selection, anim, studioOffset, callbacks, clearOffset); } else { - // Flat to()/set() — convert to keyframes first so the drag position - // is captured at the current seek time, not just the tween endpoint. + // Flat to()/from()/fromTo()/set() — convert to keyframes first so the + // drag position is captured at the current seek time as a new keyframe. const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); await commitFlatViaKeyframes( selection, @@ -334,65 +330,6 @@ async function commitFlatViaKeyframes( ); } -async function commitFromPosition( - selection: DomEditSelection, - anim: GsapAnimation, - delta: { x: number; y: number }, - callbacks: GsapDragCommitCallbacks, - beforeReload: () => void, -): Promise { - const fromX = Math.round(Number(anim.properties.x ?? 0) + delta.x); - const fromY = Math.round(Number(anim.properties.y ?? 0) + delta.y); - - await callbacks.commitMutation( - selection, - { type: "update-property", animationId: anim.id, property: "x", value: fromX }, - { label: "Move layer (GSAP from x)", skipReload: true }, - ); - await callbacks.commitMutation( - selection, - { type: "update-property", animationId: anim.id, property: "y", value: fromY }, - { label: "Move layer (GSAP from y)", softReload: true, beforeReload }, - ); -} - -// fallow-ignore-next-line complexity -async function commitFromToPosition( - selection: DomEditSelection, - anim: GsapAnimation, - delta: { x: number; y: number }, - callbacks: GsapDragCommitCallbacks, - beforeReload: () => void, -): Promise { - if (anim.fromProperties) { - const fromX = Math.round(Number(anim.fromProperties.x ?? 0) + delta.x); - const fromY = Math.round(Number(anim.fromProperties.y ?? 0) + delta.y); - await callbacks.commitMutation( - selection, - { type: "update-from-property", animationId: anim.id, property: "x", value: fromX }, - { label: "Move (GSAP from x)", skipReload: true }, - ); - await callbacks.commitMutation( - selection, - { type: "update-from-property", animationId: anim.id, property: "y", value: fromY }, - { label: "Move (GSAP from y)", skipReload: true }, - ); - } - - const toX = Math.round(Number(anim.properties.x ?? 0) + delta.x); - const toY = Math.round(Number(anim.properties.y ?? 0) + delta.y); - await callbacks.commitMutation( - selection, - { type: "update-property", animationId: anim.id, property: "x", value: toX }, - { label: "Move (GSAP to x)", skipReload: true }, - ); - await callbacks.commitMutation( - selection, - { type: "update-property", animationId: anim.id, property: "y", value: toY }, - { label: "Move (GSAP to y)", softReload: true, beforeReload }, - ); -} - // ── Runtime property reader ─────────────────────────────────────────────── export function readGsapProperty( diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index 1029fd6df..a56043fbe 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -216,6 +216,12 @@ export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map< for (const entry of result.values()) { entry.keyframes.sort((a, b) => a.percentage - b.percentage); + const seen = new Set(); + entry.keyframes = entry.keyframes.filter((kf) => { + if (seen.has(kf.percentage)) return false; + seen.add(kf.percentage); + return true; + }); } return result; } diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 5140afc31..000b26df3 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -81,6 +81,7 @@ interface UseAppHotkeysParams { onResetKeyframes: () => boolean; onDeleteSelectedKeyframes: () => void; onAfterUndoRedo?: () => void; + onToggleRecording?: () => void; } // ── Hook ── @@ -106,6 +107,7 @@ export function useAppHotkeys({ onResetKeyframes, onDeleteSelectedKeyframes, onAfterUndoRedo, + onToggleRecording, }: UseAppHotkeysParams) { const previewHotkeyWindowRef = useRef(null); const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined); @@ -215,6 +217,8 @@ export function useAppHotkeys({ onResetKeyframesRef.current = onResetKeyframes; const onDeleteSelectedKeyframesRef = useRef(onDeleteSelectedKeyframes); onDeleteSelectedKeyframesRef.current = onDeleteSelectedKeyframes; + const onToggleRecordingRef = useRef(onToggleRecording); + onToggleRecordingRef.current = onToggleRecording; // ── Consolidated keydown handler ── @@ -377,6 +381,20 @@ export function useAppHotkeys({ void handleDomEditDeleteRef.current(domSelection); } } + + // R — toggle gesture recording + if ( + event.key === "r" && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + !isEditableTarget(event.target) && + onToggleRecordingRef.current + ) { + event.preventDefault(); + onToggleRecordingRef.current(); + } }; // ── Window keydown listener ── diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index c65e87b88..3c4f73793 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -101,6 +101,8 @@ export interface UseDomEditCommitsParams { projectIdRef: React.MutableRefObject; reloadPreview: () => void; + onCommitAnimatedOffset?: (selection: DomEditSelection, next: { x: number; y: number }) => void; + // From useDomSelection domEditSelection: DomEditSelection | null; applyDomSelection: ( @@ -130,6 +132,7 @@ export function useDomEditCommits({ projectId, projectIdRef, reloadPreview, + onCommitAnimatedOffset, domEditSelection, applyDomSelection, clearDomSelection, @@ -321,13 +324,16 @@ export function useDomEditCommits({ const handleDomPathOffsetCommit = useCallback( (selection: DomEditSelection, next: { x: number; y: number }) => { applyStudioPathOffset(selection.element, next); - if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return; + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) { + onCommitAnimatedOffset?.(selection, next); + return; + } commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { label: "Move layer", coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`, }); }, - [commitPositionPatchToHtml, previewIframeRef], + [commitPositionPatchToHtml, previewIframeRef, onCommitAnimatedOffset], ); const handleDomGroupPathOffsetCommit = useCallback( diff --git a/packages/studio/src/hooks/useGestureCommit.ts b/packages/studio/src/hooks/useGestureCommit.ts new file mode 100644 index 000000000..86501341c --- /dev/null +++ b/packages/studio/src/hooks/useGestureCommit.ts @@ -0,0 +1,56 @@ +import { useCallback } from "react"; +import type { DomEditSelection } from "../components/editor/domEditing"; + +interface GestureCommitParams { + commitMutation: ( + selection: DomEditSelection, + mutation: Record, + options: { label: string; softReload: boolean }, + ) => Promise; +} + +export function useGestureCommit({ commitMutation }: GestureCommitParams) { + const commitGestureKeyframes = useCallback( + async ( + selection: DomEditSelection, + animationId: string, + keyframes: Map>, + eases: Map, + ) => { + const sortedPcts = Array.from(keyframes.keys()).sort((a, b) => a - b); + if (sortedPcts.length < 2) return; + + await commitMutation( + selection, + { type: "convert-to-keyframes" as const, animationId }, + { label: "Convert to keyframes for gesture recording", softReload: false }, + ); + + for (const pct of sortedPcts) { + const props = keyframes.get(pct); + if (!props) continue; + + for (const [prop, value] of Object.entries(props)) { + await commitMutation( + selection, + { + type: "add-keyframe" as const, + animationId, + percentage: pct, + property: prop, + value, + ease: eases.get(pct), + }, + { + label: `Record keyframe at ${pct}%`, + softReload: pct === sortedPcts[sortedPcts.length - 1], + }, + ); + } + } + }, + [commitMutation], + ); + + return { commitGestureKeyframes }; +} diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts new file mode 100644 index 000000000..9007ba2d9 --- /dev/null +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -0,0 +1,245 @@ +import { useCallback, useRef, useState } from "react"; + +// ── Types ── + +export interface GestureSample { + time: number; + properties: Record; +} + +interface Modifiers { + shift: boolean; + alt: boolean; + meta: boolean; +} + +interface AccumulatedState { + opacity: number; + scale: number; + z: number; +} + +// ── Pure mapping function ── + +/** + * Maps raw pointer deltas, scroll delta, and modifier state to GSAP property + * values. Pure and testable without DOM. + * + * Modifier-to-property mapping: + * - Plain drag -> x, y (px delta from start) + * - Scroll wheel -> z (accumulated scroll delta) + * - Shift + drag -> rotationX (vert * 0.5), rotationY (horiz * 0.5) + * - Alt + drag horiz -> rotation (horiz * 0.5) + * - Cmd/Ctrl + drag V -> opacity (vert delta mapped 0-1, clamped) + * - Cmd/Ctrl + scroll -> scale (scroll * 0.01, accumulated from 1.0) + */ +export function resolveGestureProperties( + dx: number, + dy: number, + scrollDelta: number, + modifiers: Modifiers, + accumulatedState: AccumulatedState, +): { + properties: Record; + nextState: AccumulatedState; +} { + const properties: Record = {}; + let nextOpacity = accumulatedState.opacity; + let nextScale = accumulatedState.scale; + let nextZ = accumulatedState.z; + + if (modifiers.meta) { + // Cmd/Ctrl held: vertical drag -> opacity, scroll -> scale + nextOpacity = Math.max(0, Math.min(1, accumulatedState.opacity - dy * 0.005)); + properties.opacity = nextOpacity; + + if (scrollDelta !== 0) { + nextScale = Math.max(0.01, accumulatedState.scale + scrollDelta * 0.01); + properties.scale = nextScale; + } + } else if (modifiers.shift) { + // Shift held: drag -> rotationX/rotationY + properties.rotationX = dy * 0.5; + properties.rotationY = dx * 0.5; + } else if (modifiers.alt) { + // Alt held: horizontal drag -> rotation + properties.rotation = dx * 0.5; + } else { + // Plain: drag -> x/y + properties.x = dx; + properties.y = dy; + } + + // Scroll without Cmd/Ctrl -> z accumulation + if (!modifiers.meta && scrollDelta !== 0) { + nextZ = accumulatedState.z + scrollDelta; + properties.z = nextZ; + } + + return { + properties, + nextState: { opacity: nextOpacity, scale: nextScale, z: nextZ }, + }; +} + +// ── Hook ── + +export function useGestureRecording() { + const [isRecording, setIsRecording] = useState(false); + const [samples, setSamples] = useState([]); + const [recordingDuration, setRecordingDuration] = useState(0); + + // Refs for high-frequency data (never useState for 60fps input) + const pointerRef = useRef({ x: 0, y: 0 }); + const startPointerRef = useRef({ x: 0, y: 0 }); + const scrollDeltaRef = useRef(0); + const modifiersRef = useRef({ shift: false, alt: false, meta: false }); + const accumulatedRef = useRef({ opacity: 1, scale: 1, z: 0 }); + + const rafIdRef = useRef(0); + const samplesRef = useRef([]); + const cleanupRef = useRef<(() => void) | null>(null); + + const startRecording = useCallback( + (element: HTMLElement, _iframeEl: HTMLIFrameElement) => { + if (isRecording) return; + + // Reset state + samplesRef.current = []; + setSamples([]); + setRecordingDuration(0); + accumulatedRef.current = { opacity: 1, scale: 1, z: 0 }; + scrollDeltaRef.current = 0; + + // Listeners target the overlay element (same div DomEditOverlay uses). + // Passive so they never block scrolling. + + const handlePointerMove = (e: PointerEvent) => { + pointerRef.current = { x: e.clientX, y: e.clientY }; + modifiersRef.current = { + shift: e.shiftKey, + alt: e.altKey, + meta: e.metaKey || e.ctrlKey, + }; + }; + + const handleWheel = (e: WheelEvent) => { + scrollDeltaRef.current += e.deltaY; + modifiersRef.current = { + shift: e.shiftKey, + alt: e.altKey, + meta: e.metaKey || e.ctrlKey, + }; + }; + + const handleKeyDown = (e: KeyboardEvent) => { + modifiersRef.current = { + shift: e.shiftKey, + alt: e.altKey, + meta: e.metaKey || e.ctrlKey, + }; + }; + + const handleKeyUp = (e: KeyboardEvent) => { + modifiersRef.current = { + shift: e.shiftKey, + alt: e.altKey, + meta: e.metaKey || e.ctrlKey, + }; + }; + + element.addEventListener("pointermove", handlePointerMove, { passive: true }); + element.addEventListener("wheel", handleWheel, { passive: true }); + document.addEventListener("keydown", handleKeyDown, { passive: true }); + document.addEventListener("keyup", handleKeyUp, { passive: true }); + + // Capture initial pointer position from the first move; until then delta is 0. + // We set it explicitly so the first RAF tick sees zero deltas. + startPointerRef.current = { ...pointerRef.current }; + + const startMs = performance.now(); + + // Store pointer start once the first pointermove fires + let startCaptured = false; + const captureStart = (e: PointerEvent) => { + if (!startCaptured) { + startPointerRef.current = { x: e.clientX, y: e.clientY }; + startCaptured = true; + } + }; + element.addEventListener("pointermove", captureStart, { passive: true, once: true }); + + // RAF loop — sample at ~60fps + const tick = () => { + const now = performance.now(); + const time = (now - startMs) / 1000; + + const dx = pointerRef.current.x - startPointerRef.current.x; + const dy = pointerRef.current.y - startPointerRef.current.y; + const scrollDelta = scrollDeltaRef.current; + + const { properties, nextState } = resolveGestureProperties( + dx, + dy, + scrollDelta, + modifiersRef.current, + accumulatedRef.current, + ); + + accumulatedRef.current = nextState; + // Reset scroll delta after consuming it — it's accumulated per-frame + scrollDeltaRef.current = 0; + + const sample: GestureSample = { time, properties }; + samplesRef.current.push(sample); + setRecordingDuration(time); + + rafIdRef.current = requestAnimationFrame(tick); + }; + + setIsRecording(true); + rafIdRef.current = requestAnimationFrame(tick); + + // Store cleanup so stopRecording can tear everything down + cleanupRef.current = () => { + cancelAnimationFrame(rafIdRef.current); + element.removeEventListener("pointermove", handlePointerMove); + element.removeEventListener("wheel", handleWheel); + document.removeEventListener("keydown", handleKeyDown); + document.removeEventListener("keyup", handleKeyUp); + element.removeEventListener("pointermove", captureStart); + }; + }, + [isRecording], + ); + + const stopRecording = useCallback(() => { + if (!isRecording) return; + + cleanupRef.current?.(); + cleanupRef.current = null; + + // Freeze samples into React state + const frozen = samplesRef.current.slice(); + setSamples(frozen); + setRecordingDuration(frozen.length > 0 ? frozen[frozen.length - 1].time : 0); + setIsRecording(false); + }, [isRecording]); + + const clearSamples = useCallback(() => { + samplesRef.current = []; + setSamples([]); + setRecordingDuration(0); + accumulatedRef.current = { opacity: 1, scale: 1, z: 0 }; + scrollDeltaRef.current = 0; + }, []); + + return { + startRecording, + stopRecording, + isRecording, + samples, + recordingDuration, + clearSamples, + }; +} diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 57d62c36e..95e07374e 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -201,12 +201,14 @@ export function useGsapScriptCommits({ }); } - onCacheInvalidate(); - if (result.after != null) { onFileContentChanged?.(targetPath, result.after); } + if (options.skipReload) return; + + onCacheInvalidate(); + if (result.parsed?.animations) { updateKeyframeCacheFromParsed( result.parsed.animations, @@ -216,8 +218,6 @@ export function useGsapScriptCommits({ ); } - if (options.skipReload) return; - options.beforeReload?.(); if (options.softReload && result.scriptText) { diff --git a/packages/studio/src/hooks/useToast.ts b/packages/studio/src/hooks/useToast.ts index 79ca444ab..a27147a9b 100644 --- a/packages/studio/src/hooks/useToast.ts +++ b/packages/studio/src/hooks/useToast.ts @@ -16,5 +16,10 @@ export function useToast() { if (timerRef.current) clearTimeout(timerRef.current); }); - return { appToast, showToast }; + const dismissToast = useCallback(() => { + if (timerRef.current) clearTimeout(timerRef.current); + setAppToast(null); + }, []); + + return { appToast, showToast, dismissToast }; } diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index 502f19aff..acad691e1 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -102,7 +102,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ const x2 = (kf.percentage / 100) * clipWidthPx; return (
{ + {sorted.map((kf, i) => { const leftPx = (kf.percentage / 100) * clipWidthPx - half; const kfKey = `${elementId}:${kf.percentage}`; const isKfSelected = selectedKeyframes.has(kfKey); @@ -126,7 +126,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ const color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3"; return (
)} diff --git a/packages/studio/src/components/editor/SourceEditor.tsx b/packages/studio/src/components/editor/SourceEditor.tsx index 6aac6f1fc..b64a53926 100644 --- a/packages/studio/src/components/editor/SourceEditor.tsx +++ b/packages/studio/src/components/editor/SourceEditor.tsx @@ -143,7 +143,6 @@ export const SourceEditor = memo(function SourceEditor({ selection: { anchor: pos }, effects: EditorView.scrollIntoView(pos, { y: "center" }), }); - view.focus(); }, [revealOffset]); return
; diff --git a/packages/studio/src/components/editor/manualEdits.ts b/packages/studio/src/components/editor/manualEdits.ts index 05e6a81b0..5c0e01be2 100644 --- a/packages/studio/src/components/editor/manualEdits.ts +++ b/packages/studio/src/components/editor/manualEdits.ts @@ -240,6 +240,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi "renderSeek", ); const wrappedTimelineSeek = wrapSeekReapplyFunction(studioWin, studioWin.__timeline, "seek"); + wrapSeekReapplyFunction(studioWin, studioWin.__timeline, "totalTime"); const wrappedPlayerPlay = wrapPlayReapplyFunction(studioWin, studioWin.__player, "play"); const wrappedTimelinePlay = wrapPlayReapplyFunction(studioWin, studioWin.__timeline, "play"); const wrappedPlayerPause = wrapApplyAfterFunction(studioWin, studioWin.__player, "pause"); @@ -250,6 +251,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi for (const timeline of Object.values(studioWin.__timelines ?? {})) { wrappedNamedTimelineSeek = wrapSeekReapplyFunction(studioWin, timeline, "seek") || wrappedNamedTimelineSeek; + wrapSeekReapplyFunction(studioWin, timeline, "totalTime"); wrappedNamedTimelinePlay = wrapPlayReapplyFunction(studioWin, timeline, "play") || wrappedNamedTimelinePlay; wrappedNamedTimelinePause = @@ -268,6 +270,7 @@ export function installStudioManualEditSeekReapply(win: Window, apply: () => voi if (typeof value === "object" && value !== null) { const tl = value as Record; wrapSeekReapplyFunction(studioWin, tl, "seek"); + wrapSeekReapplyFunction(studioWin, tl, "totalTime"); wrapPlayReapplyFunction(studioWin, tl, "play"); wrapApplyAfterFunction(studioWin, tl, "pause"); studioWin.__hfStudioManualEditsApply?.(); diff --git a/packages/studio/src/components/editor/manualEditsDom.ts b/packages/studio/src/components/editor/manualEditsDom.ts index 27c38e480..36ff48f85 100644 --- a/packages/studio/src/components/editor/manualEditsDom.ts +++ b/packages/studio/src/components/editor/manualEditsDom.ts @@ -273,11 +273,32 @@ export function applyStudioPathOffsetDraft( ): void { promoteInlineForTransform(element); writeStudioPathOffsetVars(element, offset, { updateBase: false }); - element.style.setProperty( - "translate", - composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`), - ); - stripGsapTranslateFromTransform(element); + + const isGsapAnimated = gsapAnimatesProperty(element, "x", "y"); + if (isGsapAnimated) { + // For GSAP-animated elements: use gsap.set for positioning (the timeline + // is paused during drag). Set translate:none explicitly to prevent + // double-counting with the transform. + element.style.setProperty("translate", "none"); + const win = element.ownerDocument.defaultView as + | (Window & { gsap?: { set: (el: Element, vars: Record) => void } }) + | null; + win?.gsap?.set(element, { x: offset.x, y: offset.y }); + const _dr = element.getBoundingClientRect(); + console.log("[DRAFT-GSAP]", { + setX: Math.round(offset.x), + setY: Math.round(offset.y), + elemLeft: Math.round(_dr.left), + elemTop: Math.round(_dr.top), + }); + } else { + // Non-GSAP elements: use CSS translate as before. + element.style.setProperty( + "translate", + composeTranslateValue(element, `${Math.round(offset.x)}px`, `${Math.round(offset.y)}px`), + ); + stripGsapTranslateFromTransform(element); + } } /* ── Box size apply ───────────────────────────────────────────────── */ @@ -505,6 +526,10 @@ function queryStudioElements(doc: Document, attr: string): HTMLElement[] { function reapplyPathOffsets(doc: Document): void { for (const el of queryStudioElements(doc, STUDIO_PATH_OFFSET_ATTR)) { + // Skip elements where GSAP actively animates position — GSAP bakes the + // CSS translate into its transform and sets translate: none every tick. + // Stripping/restoring would oscillate against GSAP's rendering. + if (gsapAnimatesProperty(el, "x", "y")) continue; const x = el.style.getPropertyValue(STUDIO_OFFSET_X_PROP); const y = el.style.getPropertyValue(STUDIO_OFFSET_Y_PROP); if (x || y) { diff --git a/packages/studio/src/components/editor/manualOffsetDrag.ts b/packages/studio/src/components/editor/manualOffsetDrag.ts index 9df465a00..3d8ee8dbd 100644 --- a/packages/studio/src/components/editor/manualOffsetDrag.ts +++ b/packages/studio/src/components/editor/manualOffsetDrag.ts @@ -232,6 +232,41 @@ export function createManualOffsetDragMember(input: { rect: ManualOffsetDragRect; }): ManualOffsetDragMemberResult { const initialOffset = readStudioPathOffset(input.element); + input.element.setAttribute("data-hf-drag-initial-offset-x", String(initialOffset.x)); + input.element.setAttribute("data-hf-drag-initial-offset-y", String(initialOffset.y)); + + // Capture GSAP's x/y BEFORE any draft applies gsap.set — the commit path + // needs the original (uncorrupted) GSAP position to compute the new keyframe value. + const win = input.element.ownerDocument.defaultView as + | (Window & { + gsap?: { getProperty?: (el: Element, prop: string) => number }; + __timelines?: Record void; paused?: () => boolean }>; + }) + | null; + const gsapX = win?.gsap?.getProperty?.(input.element, "x") || 0; + const gsapY = win?.gsap?.getProperty?.(input.element, "y") || 0; + input.element.setAttribute("data-hf-drag-gsap-base-x", String(gsapX)); + input.element.setAttribute("data-hf-drag-gsap-base-y", String(gsapY)); + + // Pause GSAP timelines during drag to prevent the tween from overwriting + // the draft's gsap.set on every tick. Track which we paused to resume later. + if (win?.__timelines) { + const paused: string[] = []; + for (const [id, tl] of Object.entries(win.__timelines)) { + try { + if (tl?.pause && !tl.paused?.()) { + tl.pause(); + paused.push(id); + } + } catch { + /* cross-origin guard */ + } + } + if (paused.length > 0) { + input.element.setAttribute("data-hf-drag-paused-timelines", paused.join(",")); + } + } + const initialPathOffset = captureStudioPathOffset(input.element); const gestureToken = beginStudioManualEditGesture(input.element); const measured = measureManualOffsetDragScreenToOffsetMatrix(input.element, initialOffset); @@ -313,11 +348,35 @@ function restoreManualOffsetDragMember(member: ManualOffsetDragMember): void { export function restoreManualOffsetDragMembers(members: ManualOffsetDragMember[]): void { for (const member of members) { restoreManualOffsetDragMember(member); + resumeGsapTimelines(member.element); } } export function endManualOffsetDragMembers(members: ManualOffsetDragMember[]): void { for (const member of members) { endStudioManualEditGesture(member.element, member.gestureToken); + member.element.removeAttribute("data-hf-drag-initial-offset-x"); + member.element.removeAttribute("data-hf-drag-initial-offset-y"); + member.element.removeAttribute("data-hf-drag-gsap-base-x"); + member.element.removeAttribute("data-hf-drag-gsap-base-y"); + resumeGsapTimelines(member.element); } } + +function resumeGsapTimelines(element: HTMLElement): void { + const ids = element.getAttribute("data-hf-drag-paused-timelines"); + element.removeAttribute("data-hf-drag-paused-timelines"); + if (!ids) return; + const win = element.ownerDocument.defaultView as + | (Window & { + __timelines?: Record void }>; + __player?: { seek?: (t: number) => void; getTime?: () => number }; + }) + | null; + if (!win) return; + // Re-seek to the current time to restore the paused timeline's render state. + // play() would start playback; pause() already stops. Seek re-renders at the + // current position without starting playback. + const t = win.__player?.getTime?.() ?? 0; + win.__player?.seek?.(t); +} diff --git a/packages/studio/src/components/editor/useDomEditOverlayRects.ts b/packages/studio/src/components/editor/useDomEditOverlayRects.ts index 0c06b84c0..a0c72e083 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayRects.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayRects.ts @@ -113,7 +113,13 @@ export function useDomEditOverlayRects({ const update = () => { frame = requestAnimationFrame(update); - if (rafPausedRef.current) return; + if (rafPausedRef.current) { + if (childRectsRef.current.length > 0) { + childRectsRef.current = []; + setChildRectsState([]); + } + return; + } const sel = selectionRef.current; const iframe = iframeRef.current; @@ -143,7 +149,8 @@ export function useDomEditOverlayRects({ resolvedElementRef as ResolvedElementRef, ); if (el && isElementVisibleForOverlay(el)) { - setOverlayRect(toOverlayRect(overlayEl, iframe, el)); + const nextRect = toOverlayRect(overlayEl, iframe, el); + setOverlayRect(nextRect); const descendants = el.querySelectorAll("*"); if (descendants.length > 0 && descendants.length <= 60) { const nextChildRects: OverlayRect[] = []; diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 985cbc57b..c62fda6b4 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -67,6 +67,7 @@ export function DomEditProvider({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, @@ -76,6 +77,7 @@ export function DomEditProvider({ handleUpdateArcSegment, invalidateGsapCache, previewIframeRef, + commitMutation, }, children, }: { @@ -138,6 +140,7 @@ export function DomEditProvider({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, @@ -147,6 +150,7 @@ export function DomEditProvider({ handleUpdateArcSegment, invalidateGsapCache, previewIframeRef, + commitMutation, }), [ domEditSelection, @@ -203,6 +207,7 @@ export function DomEditProvider({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, @@ -212,6 +217,7 @@ export function DomEditProvider({ handleUpdateArcSegment, invalidateGsapCache, previewIframeRef, + commitMutation, ], ); return {children}; diff --git a/packages/studio/src/contexts/StudioContext.tsx b/packages/studio/src/contexts/StudioContext.tsx index 1a61a33f7..48787d076 100644 --- a/packages/studio/src/contexts/StudioContext.tsx +++ b/packages/studio/src/contexts/StudioContext.tsx @@ -12,7 +12,6 @@ export interface StudioContextValue { compositionLoading: boolean; refreshKey: number; setRefreshKey: React.Dispatch>; - currentTime: number; timelineElements: TimelineElement[]; isPlaying: boolean; editHistory: { @@ -63,7 +62,6 @@ export function StudioProvider({ compositionLoading, refreshKey, setRefreshKey, - currentTime, timelineElements, isPlaying, editHistory, @@ -89,7 +87,6 @@ export function StudioProvider({ compositionLoading, refreshKey, setRefreshKey, - currentTime, timelineElements, isPlaying, editHistory, @@ -112,7 +109,6 @@ export function StudioProvider({ captionEditMode, compositionLoading, refreshKey, - currentTime, isPlaying, compositionDimensions, timelineVisible, diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 90db5ef56..73a250ed0 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -10,7 +10,7 @@ */ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import { clearStudioPathOffset } from "../components/editor/manualEdits"; + import { usePlayerStore } from "../player/store/playerStore"; import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes"; import { @@ -202,9 +202,27 @@ export async function tryGsapDragIntercept( const selector = selectorForSelection(selection); if (!selector) return false; + // Keyframe writes at 0%/100% when outside the tween range. Acceptable + // trade-off — CSS path must NEVER touch GSAP-targeted elements because + // changing the CSS offset corrupts all existing keyframes (baked mismatch). + const gsapPos = readGsapPositionFromIframe(iframe, selector); if (!gsapPos) return false; + const ct = usePlayerStore.getState().currentTime; + const ts = resolveTweenStart(posAnim); + const td = resolveTweenDuration(posAnim); + console.log("[DRAG-INTERCEPT]", { + selector, + gsapPos, + offset, + animId: posAnim.id, + currentTime: Math.round(ct * 1000) / 1000, + tweenRange: ts !== null ? `${ts}-${Math.round((ts + td) * 1000) / 1000}` : "unknown", + inRange: ts !== null ? ct >= ts - 0.01 && ct <= ts + td + 0.01 : "unknown", + hasKf: !!posAnim.keyframes, + }); + await commitGsapPositionFromDrag(selection, posAnim, offset, gsapPos, iframe, selector, { commitMutation, }); @@ -244,46 +262,179 @@ async function commitGsapPositionFromDrag( const rad = (-rotDeg * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); - const adjX = studioOffset.x * cos - studioOffset.y * sin; - const adjY = studioOffset.x * sin + studioOffset.y * cos; - const newX = Math.round(gsapPos.x + adjX); - const newY = Math.round(gsapPos.y + adjY); - const clearOffset = () => clearStudioPathOffset(selection.element); + const el = selection.element; + const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0; + const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0; + const deltaX = studioOffset.x - origX; + const deltaY = studioOffset.y - origY; + const adjX = deltaX * cos - deltaY * sin; + const adjY = deltaX * sin + deltaY * cos; + // Use the GSAP base captured at drag start — the live gsapPos is corrupted + // by the draft's gsap.set() calls during drag. + const baseGsapX = + Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "") || gsapPos.x; + const baseGsapY = + Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "") || gsapPos.y; + const newX = Math.round(baseGsapX + adjX); + const newY = Math.round(baseGsapY + adjY); + console.log("[DRAG-COMMIT]", { + newX, + newY, + baseGsap: { x: Math.round(baseGsapX), y: Math.round(baseGsapY) }, + origOffset: { x: Math.round(origX), y: Math.round(origY) }, + delta: { x: Math.round(deltaX), y: Math.round(deltaY) }, + studioOffset: { x: Math.round(studioOffset.x), y: Math.round(studioOffset.y) }, + gsapPos: { x: Math.round(gsapPos.x), y: Math.round(gsapPos.y) }, + rotation: rotDeg, + }); + // Restore the CSS offset to pre-drag value so the baked translate stays + // consistent with existing keyframes. The drag is captured in the new keyframe. + const restoreOffset = () => { + el.style.setProperty("--hf-studio-offset-x", `${origX}px`); + el.style.setProperty("--hf-studio-offset-y", `${origY}px`); + el.removeAttribute("data-hf-drag-initial-offset-x"); + el.removeAttribute("data-hf-drag-initial-offset-y"); + }; if (anim.keyframes) { const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); const effectiveAnim = newId ? { ...anim, id: newId } : anim; const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); - await commitKeyframedPosition( + + // Check if current time is outside the tween's range — extend the tween + // to cover the playhead, remap existing keyframes, then add the new one. + const ct = usePlayerStore.getState().currentTime; + const ts = resolveTweenStart(effectiveAnim); + const td = resolveTweenDuration(effectiveAnim); + if (ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01)) { + await extendTweenAndAddKeyframe( + selection, + effectiveAnim, + { ...runtimeProps, x: newX, y: newY }, + ct, + ts, + td, + callbacks, + restoreOffset, + ); + } else { + await commitKeyframedPosition( + selection, + effectiveAnim, + { ...runtimeProps, x: newX, y: newY }, + callbacks, + restoreOffset, + ); + } + } else if (anim.method === "from" || anim.method === "fromTo") { + // from()/fromTo() — convert to keyframes in a single mutation, placing + // the dragged position at the 100% (rest) keyframe. A single mutation + // avoids the stable-id flip (from→to) that breaks chained mutations. + await callbacks.commitMutation( selection, - effectiveAnim, - { ...runtimeProps, x: newX, y: newY }, - callbacks, - clearOffset, + { + type: "convert-to-keyframes", + animationId: anim.id, + resolvedFromValues: { x: newX, y: newY }, + }, + { label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset }, ); } else { - // Flat to()/from()/fromTo()/set() — convert to keyframes first so the - // drag position is captured at the current seek time as a new keyframe. + // Flat to()/set() — convert to keyframes then add at current percentage. const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); await commitFlatViaKeyframes( selection, anim, { ...runtimeProps, x: newX, y: newY }, callbacks, - clearOffset, + restoreOffset, ); } } +/** + * Extend a tween's time range to cover `targetTime`, remap all existing + * keyframe percentages to preserve their absolute positions, then add + * a new keyframe at the target time. + */ +async function extendTweenAndAddKeyframe( + selection: DomEditSelection, + anim: GsapAnimation, + properties: Record, + targetTime: number, + tweenStart: number, + tweenDuration: number, + callbacks: GsapDragCommitCallbacks, + beforeReload?: () => void, +): Promise { + const tweenEnd = tweenStart + tweenDuration; + const newStart = Math.min(targetTime, tweenStart); + const newEnd = Math.max(targetTime, tweenEnd); + const newDuration = Math.max(0.01, newEnd - newStart); + console.log("[DRAG-EXTEND]", { + targetTime: Math.round(targetTime * 1000) / 1000, + oldRange: `${tweenStart}-${Math.round(tweenEnd * 1000) / 1000}`, + newRange: `${Math.round(newStart * 1000) / 1000}-${Math.round(newEnd * 1000) / 1000}`, + existingKfCount: anim.keyframes?.keyframes.length ?? 0, + animId: anim.id, + }); + + // Step 1: Remap all existing keyframes to preserve their absolute times + // in the new range, then add the new keyframe. + const existingKfs = anim.keyframes?.keyframes ?? []; + const remappedKfs: Array<{ percentage: number; properties: Record }> = + []; + for (const kf of existingKfs) { + const absTime = tweenStart + (kf.percentage / 100) * tweenDuration; + const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10; + remappedKfs.push({ percentage: newPct, properties: { ...kf.properties } }); + } + + // Add the new keyframe at the target time + const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10; + remappedKfs.push({ percentage: targetPct, properties }); + + // Sort and dedupe + remappedKfs.sort((a, b) => a.percentage - b.percentage); + + // Step 2: Delete the old tween and create a new one with the extended range + // and all remapped keyframes. Using delete + add-with-keyframes as an atomic pair. + await callbacks.commitMutation( + selection, + { type: "delete", animationId: anim.id }, + { label: "Extend tween range", skipReload: true }, + ); + + const selector = anim.targetSelector; + await callbacks.commitMutation( + selection, + { + type: "add-with-keyframes", + targetSelector: selector, + position: Math.round(newStart * 1000) / 1000, + duration: Math.round(newDuration * 1000) / 1000, + keyframes: remappedKfs, + }, + { label: `Move layer (extended keyframe)`, softReload: true, beforeReload }, + ); +} + // fallow-ignore-next-line complexity async function commitKeyframedPosition( selection: DomEditSelection, anim: GsapAnimation, properties: Record, callbacks: GsapDragCommitCallbacks, - beforeReload: () => void, + beforeReload?: () => void, ): Promise { const pct = computeCurrentPercentage(selection, anim); + console.log("[DRAG-KF]", { + animId: anim.id, + pct, + x: properties.x, + y: properties.y, + currentTime: Math.round(usePlayerStore.getState().currentTime * 1000) / 1000, + }); await callbacks.commitMutation( selection, @@ -308,7 +459,7 @@ async function commitFlatViaKeyframes( anim: GsapAnimation, properties: Record, callbacks: GsapDragCommitCallbacks, - beforeReload: () => void, + beforeReload?: () => void, ): Promise { await callbacks.commitMutation( selection, diff --git a/packages/studio/src/hooks/useAskAgentModal.ts b/packages/studio/src/hooks/useAskAgentModal.ts index ece327998..f88f3f261 100644 --- a/packages/studio/src/hooks/useAskAgentModal.ts +++ b/packages/studio/src/hooks/useAskAgentModal.ts @@ -3,6 +3,7 @@ import { copyTextToClipboard } from "../utils/clipboard"; import { readTagSnippetByTarget } from "../utils/sourcePatcher"; import { toProjectAbsolutePath, type AgentModalAnchorPoint } from "../utils/studioHelpers"; import { buildElementAgentPrompt, type DomEditSelection } from "../components/editor/domEditing"; +import { usePlayerStore } from "../player"; // ── Types ── @@ -11,7 +12,6 @@ export interface UseAskAgentModalParams { activeCompPath: string | null; projectDir: string | null; projectIdRef: React.MutableRefObject; - currentTime: number; showToast: (message: string, tone?: "error" | "info") => void; domEditSelectionRef: React.MutableRefObject; domEditSelection: DomEditSelection | null; @@ -23,7 +23,6 @@ export function useAskAgentModal({ activeCompPath, projectDir, projectIdRef, - currentTime, showToast, domEditSelectionRef, domEditSelection, @@ -91,7 +90,7 @@ export function useAskAgentModal({ const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML; const prompt = buildElementAgentPrompt({ selection: domEditSelection, - currentTime, + currentTime: usePlayerStore.getState().currentTime, tagSnippet, selectionContext: agentPromptSelectionContext, userInstruction, @@ -115,7 +114,6 @@ export function useAskAgentModal({ activeCompPath, agentPromptSelectionContext, agentPromptTagSnippet, - currentTime, domEditSelection, projectDir, showToast, diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index 3c4f73793..c65e87b88 100644 --- a/packages/studio/src/hooks/useDomEditCommits.ts +++ b/packages/studio/src/hooks/useDomEditCommits.ts @@ -101,8 +101,6 @@ export interface UseDomEditCommitsParams { projectIdRef: React.MutableRefObject; reloadPreview: () => void; - onCommitAnimatedOffset?: (selection: DomEditSelection, next: { x: number; y: number }) => void; - // From useDomSelection domEditSelection: DomEditSelection | null; applyDomSelection: ( @@ -132,7 +130,6 @@ export function useDomEditCommits({ projectId, projectIdRef, reloadPreview, - onCommitAnimatedOffset, domEditSelection, applyDomSelection, clearDomSelection, @@ -324,16 +321,13 @@ export function useDomEditCommits({ const handleDomPathOffsetCommit = useCallback( (selection: DomEditSelection, next: { x: number; y: number }) => { applyStudioPathOffset(selection.element, next); - if (isElementGsapTargeted(previewIframeRef.current, selection.element)) { - onCommitAnimatedOffset?.(selection, next); - return; - } + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return; commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { label: "Move layer", coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`, }); }, - [commitPositionPatchToHtml, previewIframeRef, onCommitAnimatedOffset], + [commitPositionPatchToHtml, previewIframeRef], ); const handleDomGroupPathOffsetCommit = useCallback( diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 02013491e..f4fa2ce3c 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -50,7 +50,6 @@ export interface UseDomEditSessionParams { compositionLoading: boolean; previewIframeRef: React.MutableRefObject; timelineElements: TimelineElement[]; - currentTime: number; setSelectedTimelineElementId: (id: string | null) => void; setRightCollapsed: (collapsed: boolean) => void; setRightPanelTab: (tab: RightPanelTab) => void; @@ -92,7 +91,6 @@ export function useDomEditSession({ compositionLoading, previewIframeRef, timelineElements, - currentTime, setSelectedTimelineElementId, setRightCollapsed, setRightPanelTab, @@ -184,7 +182,6 @@ export function useDomEditSession({ activeCompPath, projectDir, projectIdRef, - currentTime, showToast, domEditSelectionRef, domEditSelection, @@ -272,6 +269,7 @@ export function useDomEditSession({ addGsapFromProperty, removeGsapFromProperty, addKeyframe, + addKeyframeBatch, removeKeyframe, convertToKeyframes, removeAllKeyframes, @@ -434,6 +432,7 @@ export function useDomEditSession({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, @@ -450,10 +449,10 @@ export function useDomEditSession({ addGsapFromProperty, removeGsapFromProperty, addKeyframe, + addKeyframeBatch, removeKeyframe, convertToKeyframes, removeAllKeyframes, - currentTime, handleDomManualEditsReset, selectedGsapAnimations, }); @@ -623,6 +622,7 @@ export function useDomEditSession({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, @@ -632,5 +632,12 @@ export function useDomEditSession({ handleUpdateArcSegment, invalidateGsapCache: bumpGsapCache, previewIframeRef, + commitMutation: async ( + mutation: Record, + options: { label: string; softReload?: boolean }, + ) => { + if (!domEditSelection) return; + await gsapCommitMutation(domEditSelection, mutation, options); + }, }; } diff --git a/packages/studio/src/hooks/useEnableKeyframes.ts b/packages/studio/src/hooks/useEnableKeyframes.ts new file mode 100644 index 000000000..065cd74be --- /dev/null +++ b/packages/studio/src/hooks/useEnableKeyframes.ts @@ -0,0 +1,167 @@ +/** + * Centralized "Enable keyframes" logic that handles ALL scenarios: + * - Element has explicit keyframes → add/remove at seeked time + * - Element has a flat tween → convert + add at seeked time + propagate to end + * - Element has no animation (deleted) → create new tween with correct position + keyframes + * + * Always fetches fresh animation data to avoid stale session state. + * Reads GSAP runtime values only (no CSS offset — it applies separately via translate). + */ +import { useCallback } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { usePlayerStore } from "../player/store/playerStore"; +import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache"; + +export interface EnableKeyframesSession { + domEditSelection: DomEditSelection | null; + selectedGsapAnimations: GsapAnimation[]; + previewIframeRef?: React.RefObject; + handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void; + handleGsapConvertToKeyframes: ( + animId: string, + resolvedFromValues?: Record, + ) => void | Promise; + handleGsapRemoveKeyframe: (animId: string, pct: number) => void; + handleGsapAddKeyframeBatch?: ( + animId: string, + pct: number, + properties: Record, + ) => Promise; + commitMutation?: ( + mutation: Record, + options: { label: string; softReload?: boolean }, + ) => Promise; +} + +function readElementPosition( + iframe: HTMLIFrameElement | null, + sel: DomEditSelection, + anim: GsapAnimation | null, +): Record { + const result: Record = {}; + if (!iframe?.contentWindow) return result; + + let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined; + try { + gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap; + } catch { + return result; + } + + const element = sel.element; + if (!element?.isConnected || !gsap?.getProperty) return result; + + const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"]; + for (const prop of props) { + const val = Number(gsap.getProperty(element, prop)); + if (Number.isFinite(val)) result[prop] = Math.round(val); + } + + return result; +} + +async function fetchAnimationsForElement(sel: DomEditSelection): Promise { + const projectId = window.location.hash.match(/project\/([^?/]+)/)?.[1]; + if (!projectId) return []; + const sourceFile = sel.sourceFile || "index.html"; + const parsed = await fetchParsedAnimations(projectId, sourceFile); + if (!parsed) return []; + return getAnimationsForElement(parsed.animations, { + id: sel.id, + selector: sel.selector, + }); +} + +function computePercentage(t: number, sel: DomEditSelection): number { + const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; + if (elDuration <= 0) return 0; + return Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10)); +} + +// fallow-ignore-next-line complexity +export function useEnableKeyframes( + sessionRef: React.RefObject, +) { + return useCallback(async () => { + const session = sessionRef.current; + if (!session) return; + const sel = session.domEditSelection; + if (!sel) return; + + const t = usePlayerStore.getState().currentTime; + const iframe = session.previewIframeRef?.current ?? null; + + let anims = session.selectedGsapAnimations; + if (anims.length === 0) { + anims = await fetchAnimationsForElement(sel); + } + + const kfAnim = anims.find((a) => a.keyframes); + const flatAnim = anims.find((a) => !a.keyframes); + + if (kfAnim?.keyframes) { + const pct = computePercentage(t, sel); + const existing = kfAnim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1); + if (existing) { + session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage); + } else if (session.handleGsapAddKeyframeBatch) { + const position = readElementPosition(iframe, sel, kfAnim); + if (Object.keys(position).length > 0) { + await session.handleGsapAddKeyframeBatch(kfAnim.id, pct, position); + } + } + } else if (flatAnim) { + const position = readElementPosition(iframe, sel, flatAnim); + const hasPosition = Object.keys(position).length > 0; + + await session.handleGsapConvertToKeyframes(flatAnim.id, hasPosition ? position : undefined); + + const pct = computePercentage(t, sel); + if (pct > 1 && pct < 99 && hasPosition && session.handleGsapAddKeyframeBatch) { + await session.handleGsapAddKeyframeBatch(flatAnim.id, pct, position); + await session.handleGsapAddKeyframeBatch(flatAnim.id, 100, position); + } + } else { + const position = readElementPosition(iframe, sel, null); + const pct = computePercentage(t, sel); + const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; + const selector = sel.id ? `#${sel.id}` : sel.selector; + + if (!selector) { + session.handleGsapAddAnimation("to"); + return; + } + + if (Object.keys(position).length === 0) { + position.x = 0; + position.y = 0; + position.opacity = 1; + } + + const keyframes: Array<{ percentage: number; properties: Record }> = + [{ percentage: 0, properties: { ...position } }]; + if (pct > 1 && pct < 99) { + keyframes.push({ percentage: pct, properties: { ...position } }); + } + keyframes.push({ percentage: 100, properties: { ...position } }); + + if (session.commitMutation) { + await session.commitMutation( + { + type: "add-with-keyframes", + targetSelector: selector, + position: Math.round(elStart * 1000) / 1000, + duration: Math.round(elDuration * 1000) / 1000, + keyframes, + }, + { label: "Enable keyframes", softReload: true }, + ); + } else { + session.handleGsapAddAnimation("to"); + } + } + }, [sessionRef]); +} diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts index 9007ba2d9..36d560dcd 100644 --- a/packages/studio/src/hooks/useGestureRecording.ts +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -1,6 +1,5 @@ -import { useCallback, useRef, useState } from "react"; - -// ── Types ── +import { useCallback, useEffect, useRef, useState } from "react"; +import { usePlayerStore, liveTime } from "../player/store/playerStore"; export interface GestureSample { time: number; @@ -19,20 +18,6 @@ interface AccumulatedState { z: number; } -// ── Pure mapping function ── - -/** - * Maps raw pointer deltas, scroll delta, and modifier state to GSAP property - * values. Pure and testable without DOM. - * - * Modifier-to-property mapping: - * - Plain drag -> x, y (px delta from start) - * - Scroll wheel -> z (accumulated scroll delta) - * - Shift + drag -> rotationX (vert * 0.5), rotationY (horiz * 0.5) - * - Alt + drag horiz -> rotation (horiz * 0.5) - * - Cmd/Ctrl + drag V -> opacity (vert delta mapped 0-1, clamped) - * - Cmd/Ctrl + scroll -> scale (scroll * 0.01, accumulated from 1.0) - */ export function resolveGestureProperties( dx: number, dy: number, @@ -49,28 +34,24 @@ export function resolveGestureProperties( let nextZ = accumulatedState.z; if (modifiers.meta) { - // Cmd/Ctrl held: vertical drag -> opacity, scroll -> scale - nextOpacity = Math.max(0, Math.min(1, accumulatedState.opacity - dy * 0.005)); + // Opacity derived from total vertical displacement (absolute, not accumulated). + // Dragging down reduces opacity; dragging back up restores it. + nextOpacity = Math.max(0, Math.min(1, 1 - dy * 0.005)); properties.opacity = nextOpacity; - if (scrollDelta !== 0) { nextScale = Math.max(0.01, accumulatedState.scale + scrollDelta * 0.01); properties.scale = nextScale; } } else if (modifiers.shift) { - // Shift held: drag -> rotationX/rotationY properties.rotationX = dy * 0.5; properties.rotationY = dx * 0.5; } else if (modifiers.alt) { - // Alt held: horizontal drag -> rotation properties.rotation = dx * 0.5; } else { - // Plain: drag -> x/y properties.x = dx; properties.y = dy; } - // Scroll without Cmd/Ctrl -> z accumulation if (!modifiers.meta && scrollDelta !== 0) { nextZ = accumulatedState.z + scrollDelta; properties.z = nextZ; @@ -82,37 +63,143 @@ export function resolveGestureProperties( }; } -// ── Hook ── - export function useGestureRecording() { const [isRecording, setIsRecording] = useState(false); - const [samples, setSamples] = useState([]); const [recordingDuration, setRecordingDuration] = useState(0); - // Refs for high-frequency data (never useState for 60fps input) + // Synchronous guard — immune to React's async state batching. + // startRecording and stopRecording check this ref, not the useState value. + const isRecordingRef = useRef(false); + const pointerRef = useRef({ x: 0, y: 0 }); const startPointerRef = useRef({ x: 0, y: 0 }); const scrollDeltaRef = useRef(0); const modifiersRef = useRef({ shift: false, alt: false, meta: false }); const accumulatedRef = useRef({ opacity: 1, scale: 1, z: 0 }); + const basePositionRef = useRef({ x: 0, y: 0 }); + const scaleRef = useRef(1); + const hasMovedRef = useRef(false); + const pointerElementOffsetRef = useRef({ x: 0, y: 0 }); + const runtimeRef = useRef<{ + seek: (t: number) => void; + set: (target: string, vars: Record) => void; + selector: string; + element: HTMLElement; + startTime: number; + maxSeekTime: number; + } | null>(null); const rafIdRef = useRef(0); const samplesRef = useRef([]); + const trailRef = useRef>([]); const cleanupRef = useRef<(() => void) | null>(null); + // Unmount safety: cancel RAF + remove listeners if component tears down mid-recording. + useEffect(() => { + return () => { + cleanupRef.current?.(); + cleanupRef.current = null; + isRecordingRef.current = false; + }; + }, []); + const startRecording = useCallback( - (element: HTMLElement, _iframeEl: HTMLIFrameElement) => { - if (isRecording) return; + (element: HTMLElement, iframeEl: HTMLIFrameElement, elementEndTime?: number) => { + if (isRecordingRef.current) return; + isRecordingRef.current = true; - // Reset state samplesRef.current = []; - setSamples([]); + trailRef.current = []; + hasMovedRef.current = false; setRecordingDuration(0); - accumulatedRef.current = { opacity: 1, scale: 1, z: 0 }; scrollDeltaRef.current = 0; - // Listeners target the overlay element (same div DomEditOverlay uses). - // Passive so they never block scrolling. + let baseOpacity = 1; + let baseScaleVal = 1; + let baseX = 0; + let baseY = 0; + try { + const gsap = ( + iframeEl.contentWindow as Window & { + gsap?: { getProperty: (el: Element, prop: string) => number }; + } + ).gsap; + if (gsap?.getProperty) { + baseOpacity = Number(gsap.getProperty(element, "opacity")) || 1; + baseScaleVal = Number(gsap.getProperty(element, "scaleX")) || 1; + baseX = Number(gsap.getProperty(element, "x")) || 0; + baseY = Number(gsap.getProperty(element, "y")) || 0; + } + } catch { + /* cross-origin guard */ + } + // When reapplyPathOffsets has run (translate restored to var-based), + // GSAP's cache was stripped — gsapX is 0 but the element is visually + // at CSSLeft + translate(offset). gsap.set wipes translate, so we need + // baseX to include the offset. When translate is "none" (GSAP owns it), + // gsapX already includes the baked offset — don't add. + const translateVal = element.style.translate ?? ""; + if (translateVal.includes("var(")) { + const offX = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-x")) || 0; + const offY = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-y")) || 0; + baseX += offX; + baseY += offY; + } + accumulatedRef.current = { opacity: baseOpacity, scale: baseScaleVal, z: 0 }; + basePositionRef.current = { x: baseX, y: baseY }; + const elR = element.getBoundingClientRect(); + console.log("[REC-START]", { + baseX: Math.round(baseX), + baseY: Math.round(baseY), + gsapRawX: Number((iframeEl.contentWindow as any)?.gsap?.getProperty(element, "x") ?? 0), + gsapRawY: Number((iframeEl.contentWindow as any)?.gsap?.getProperty(element, "y") ?? 0), + cssOffX: element.style.getPropertyValue("--hf-studio-offset-x"), + cssOffY: element.style.getPropertyValue("--hf-studio-offset-y"), + translate: element.style.translate, + transform: element.style.transform?.substring(0, 50), + cssLeft: element.ownerDocument.defaultView?.getComputedStyle(element).left, + cssTop: element.ownerDocument.defaultView?.getComputedStyle(element).top, + elemRect: { left: Math.round(elR.left), top: Math.round(elR.top) }, + }); + + const selector = element.id ? `#${element.id}` : null; + try { + const win = iframeEl.contentWindow as Window & { + gsap?: { set: (t: string, v: Record) => void }; + __timelines?: Record void; duration: () => number }>; + __player?: { getTime: () => number }; + }; + const tl = win?.__timelines ? Object.values(win.__timelines)[0] : null; + if (win?.gsap?.set && tl?.seek && selector) { + const tlDuration = tl.duration(); + runtimeRef.current = { + seek: tl.seek.bind(tl), + set: win.gsap.set.bind(win.gsap), + selector, + element, + startTime: win.__player?.getTime() ?? 0, + maxSeekTime: + elementEndTime != null && elementEndTime < tlDuration ? elementEndTime : tlDuration, + }; + } + } catch { + runtimeRef.current = null; + } + + const iframeRect = iframeEl.getBoundingClientRect(); + const doc = iframeEl.contentDocument; + const root = doc?.querySelector("[data-composition-id]") ?? doc?.documentElement; + const declaredWidth = Number(root?.getAttribute("data-width")) || 1920; + scaleRef.current = declaredWidth > 0 ? iframeRect.width / declaredWidth : 1; + + // Compute the offset between the element's visual center and the pointer + // so the element tracks the pointer exactly during recording (no jump). + const elRect = element.getBoundingClientRect(); + const elCenterViewport = { + x: elRect.left + elRect.width / 2, + y: elRect.top + elRect.height / 2, + }; + pointerElementOffsetRef.current = { x: 0, y: 0 }; // reset; set on first move const handlePointerMove = (e: PointerEvent) => { pointerRef.current = { x: e.clientX, y: e.clientY }; @@ -132,15 +219,7 @@ export function useGestureRecording() { }; }; - const handleKeyDown = (e: KeyboardEvent) => { - modifiersRef.current = { - shift: e.shiftKey, - alt: e.altKey, - meta: e.metaKey || e.ctrlKey, - }; - }; - - const handleKeyUp = (e: KeyboardEvent) => { + const handleKeyChange = (e: KeyboardEvent) => { modifiersRef.current = { shift: e.shiftKey, alt: e.altKey, @@ -148,36 +227,46 @@ export function useGestureRecording() { }; }; - element.addEventListener("pointermove", handlePointerMove, { passive: true }); - element.addEventListener("wheel", handleWheel, { passive: true }); - document.addEventListener("keydown", handleKeyDown, { passive: true }); - document.addEventListener("keyup", handleKeyUp, { passive: true }); + document.addEventListener("pointermove", handlePointerMove, { passive: true }); + document.addEventListener("wheel", handleWheel, { passive: true }); + document.addEventListener("keydown", handleKeyChange, { passive: true }); + document.addEventListener("keyup", handleKeyChange, { passive: true }); - // Capture initial pointer position from the first move; until then delta is 0. - // We set it explicitly so the first RAF tick sees zero deltas. startPointerRef.current = { ...pointerRef.current }; - const startMs = performance.now(); - // Store pointer start once the first pointermove fires let startCaptured = false; const captureStart = (e: PointerEvent) => { if (!startCaptured) { startPointerRef.current = { x: e.clientX, y: e.clientY }; + // Compute the offset between the pointer and the element center + // so the element follows the pointer without jumping. + pointerElementOffsetRef.current = { + x: e.clientX - elCenterViewport.x, + y: e.clientY - elCenterViewport.y, + }; startCaptured = true; + hasMovedRef.current = true; } }; - element.addEventListener("pointermove", captureStart, { passive: true, once: true }); + document.addEventListener("pointermove", captureStart, { passive: true, once: true }); - // RAF loop — sample at ~60fps const tick = () => { + if (!isRecordingRef.current) return; const now = performance.now(); const time = (now - startMs) / 1000; - - const dx = pointerRef.current.x - startPointerRef.current.x; - const dy = pointerRef.current.y - startPointerRef.current.y; + const scale = scaleRef.current || 1; + const dx = (pointerRef.current.x - startPointerRef.current.x) / scale; + const dy = (pointerRef.current.y - startPointerRef.current.y) / scale; const scrollDelta = scrollDeltaRef.current; + // Skip zero-displacement samples before the pointer has moved. + if (!hasMovedRef.current && dx === 0 && dy === 0 && scrollDelta === 0) { + rafIdRef.current = requestAnimationFrame(tick); + return; + } + hasMovedRef.current = true; + const { properties, nextState } = resolveGestureProperties( dx, dy, @@ -185,50 +274,90 @@ export function useGestureRecording() { modifiersRef.current, accumulatedRef.current, ); + if ("x" in properties) properties.x = Math.round(basePositionRef.current.x + properties.x); + if ("y" in properties) properties.y = Math.round(basePositionRef.current.y + properties.y); accumulatedRef.current = nextState; - // Reset scroll delta after consuming it — it's accumulated per-frame scrollDeltaRef.current = 0; - const sample: GestureSample = { time, properties }; - samplesRef.current.push(sample); - setRecordingDuration(time); + // Manual seek on the raw GSAP timeline (not the Studio player wrapper, + // which triggers React state updates). After seek renders all elements + // at the correct time, gsap.set overrides the recorded element so it + // follows the pointer. The browser paints the set values on this frame; + // next tick's seek will overwrite, but we re-apply immediately. + if (runtimeRef.current) { + try { + const seekTime = Math.min( + runtimeRef.current.startTime + time, + runtimeRef.current.maxSeekTime, + ); + runtimeRef.current.seek(seekTime); + runtimeRef.current.set(runtimeRef.current.selector, { ...properties }); + if (time < 0.15) { + const r = runtimeRef.current.element.getBoundingClientRect(); + const el = runtimeRef.current.element; + console.log("[REC-TICK]", { + time: time.toFixed(3), + dx: Math.round(dx), + dy: Math.round(dy), + setX: Math.round(properties.x ?? 0), + setY: Math.round(properties.y ?? 0), + elemLeft: Math.round(r.left), + elemTop: Math.round(r.top), + ptrX: Math.round(pointerRef.current.x), + ptrY: Math.round(pointerRef.current.y), + offsetFromPtr: { + x: Math.round(r.left - pointerRef.current.x), + y: Math.round(r.top - pointerRef.current.y), + }, + translate: el.style.translate, + transform: el.style.transform?.substring(0, 40), + }); + } + runtimeRef.current.element.style.visibility = "visible"; + liveTime.notify(seekTime); + usePlayerStore.getState().setCurrentTime(seekTime); + } catch { + runtimeRef.current = null; + } + } + samplesRef.current.push({ time, properties }); + trailRef.current.push({ x: pointerRef.current.x, y: pointerRef.current.y }); + setRecordingDuration(time); rafIdRef.current = requestAnimationFrame(tick); }; setIsRecording(true); rafIdRef.current = requestAnimationFrame(tick); - // Store cleanup so stopRecording can tear everything down cleanupRef.current = () => { cancelAnimationFrame(rafIdRef.current); - element.removeEventListener("pointermove", handlePointerMove); - element.removeEventListener("wheel", handleWheel); - document.removeEventListener("keydown", handleKeyDown); - document.removeEventListener("keyup", handleKeyUp); - element.removeEventListener("pointermove", captureStart); + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("wheel", handleWheel); + document.removeEventListener("keydown", handleKeyChange); + document.removeEventListener("keyup", handleKeyChange); + document.removeEventListener("pointermove", captureStart); }; }, - [isRecording], + [], // No deps — uses refs only for all mutable state ); - const stopRecording = useCallback(() => { - if (!isRecording) return; - + const stopRecording = useCallback((): GestureSample[] => { + if (!isRecordingRef.current) return []; + isRecordingRef.current = false; + runtimeRef.current = null; cleanupRef.current?.(); cleanupRef.current = null; - - // Freeze samples into React state const frozen = samplesRef.current.slice(); - setSamples(frozen); - setRecordingDuration(frozen.length > 0 ? frozen[frozen.length - 1].time : 0); + setRecordingDuration(frozen.length > 0 ? frozen[frozen.length - 1]!.time : 0); setIsRecording(false); - }, [isRecording]); + return frozen; + }, []); // No deps — uses refs only const clearSamples = useCallback(() => { samplesRef.current = []; - setSamples([]); + trailRef.current = []; setRecordingDuration(0); accumulatedRef.current = { opacity: 1, scale: 1, z: 0 }; scrollDeltaRef.current = 0; @@ -238,7 +367,8 @@ export function useGestureRecording() { startRecording, stopRecording, isRecording, - samples, + samplesRef, + trailRef, recordingDuration, clearSamples, }; diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 95e07374e..e5ef6d762 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -78,17 +78,34 @@ function updateKeyframeCacheFromParsed( selectionId: string | undefined, mutation: Record, ): void { - const { setKeyframeCache } = usePlayerStore.getState(); + const { setKeyframeCache, elements } = usePlayerStore.getState(); const idsWithKeyframes = new Set(); const merged = new Map(); for (const anim of animations) { const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1]; if (!id || !anim.keyframes) continue; idsWithKeyframes.add(id); + + // Convert tween-relative percentages to clip-relative so diamonds + // render at the correct position within the timeline clip. + const tweenPos = 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 ?? 4; + const clipKeyframes = anim.keyframes.keyframes.map((kf) => { + const absTime = tweenPos + (kf.percentage / 100) * tweenDur; + const clipPct = + elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage; + return { ...kf, percentage: clipPct }; + }); + const existing = merged.get(id); if (existing) { const byPct = new Map(); - for (const kf of [...existing.keyframes, ...anim.keyframes.keyframes]) { + for (const kf of [...existing.keyframes, ...clipKeyframes]) { const prev = byPct.get(kf.percentage); if (prev) { prev.properties = { ...prev.properties, ...kf.properties }; @@ -99,11 +116,12 @@ function updateKeyframeCacheFromParsed( } existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage); } else { - merged.set(id, { ...anim.keyframes, keyframes: [...anim.keyframes.keyframes] }); + merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes }); } } for (const [id, entry] of merged) { setKeyframeCache(`${targetPath}#${id}`, entry); + setKeyframeCache(id, entry); if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry); } const targetId = @@ -207,8 +225,14 @@ export function useGsapScriptCommits({ if (options.skipReload) return; - onCacheInvalidate(); - + // Write the keyframe cache immediately from the parsed response + // (synchronous — the timeline diamonds appear on the next render). + console.log("[MUTATION]", { + type: (mutation as Record).type, + hasParsed: !!result.parsed, + animCount: result.parsed?.animations?.length, + targetId: selection.id, + }); if (result.parsed?.animations) { updateKeyframeCacheFromParsed( result.parsed.animations, @@ -227,6 +251,11 @@ export function useGsapScriptCommits({ } else { reloadPreview(); } + + // Bump the cache version AFTER reload so the async re-fetch in + // useGsapAnimationsForElement reads the post-reload script, not + // the stale pre-reload version that would overwrite fresh data. + onCacheInvalidate(); }, [ projectIdRef, @@ -467,6 +496,21 @@ export function useGsapScriptCommits({ }, [commitMutation, activeCompPath], ); + const addKeyframeBatch = useCallback( + ( + selection: DomEditSelection, + animationId: string, + percentage: number, + properties: Record, + ) => { + return commitMutation( + selection, + { type: "add-keyframe", animationId, percentage, properties }, + { label: `Add keyframe at ${percentage}%`, softReload: true }, + ); + }, + [commitMutation], + ); const removeKeyframe = useCallback( (selection: DomEditSelection, animationId: string, percentage: number) => { const sf = selection.sourceFile || activeCompPath || "index.html"; @@ -499,7 +543,7 @@ export function useGsapScriptCommits({ animationId: string, resolvedFromValues?: Record, ) => { - void commitMutation( + return commitMutation( selection, { type: "convert-to-keyframes", animationId, resolvedFromValues }, { label: "Convert to keyframes" }, @@ -589,6 +633,7 @@ export function useGsapScriptCommits({ addGsapFromProperty, removeGsapFromProperty, addKeyframe, + addKeyframeBatch, removeKeyframe, convertToKeyframes, removeAllKeyframes, diff --git a/packages/studio/src/hooks/useGsapSelectionHandlers.ts b/packages/studio/src/hooks/useGsapSelectionHandlers.ts index 60f288d8e..aa8db6da4 100644 --- a/packages/studio/src/hooks/useGsapSelectionHandlers.ts +++ b/packages/studio/src/hooks/useGsapSelectionHandlers.ts @@ -1,5 +1,6 @@ import { useCallback } from "react"; import type { DomEditSelection } from "../components/editor/domEditing"; +import { usePlayerStore } from "../player"; /** * Thin useCallback wrappers that guard on `domEditSelection` before @@ -19,10 +20,10 @@ export function useGsapSelectionHandlers({ addGsapFromProperty, removeGsapFromProperty, addKeyframe, + addKeyframeBatch, removeKeyframe, convertToKeyframes, removeAllKeyframes, - currentTime, handleDomManualEditsReset, selectedGsapAnimations, }: { @@ -61,6 +62,12 @@ export function useGsapSelectionHandlers({ property: string, value: number | string, ) => void; + addKeyframeBatch: ( + sel: DomEditSelection, + animId: string, + percentage: number, + properties: Record, + ) => Promise; removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void; convertToKeyframes: ( sel: DomEditSelection, @@ -68,7 +75,7 @@ export function useGsapSelectionHandlers({ resolvedFromValues?: Record, ) => void; removeAllKeyframes: (sel: DomEditSelection, animId: string) => void; - currentTime: number; + handleDomManualEditsReset: (sel: DomEditSelection) => void; selectedGsapAnimations: { id: string; keyframes?: unknown }[]; }) { @@ -99,12 +106,12 @@ export function useGsapSelectionHandlers({ const handleGsapAddAnimation = useCallback( (method: "to" | "from" | "set" | "fromTo") => { if (!domEditSelection) return; - addGsapAnimation(domEditSelection, method, currentTime); + addGsapAnimation(domEditSelection, method, usePlayerStore.getState().currentTime); if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) { handleDomManualEditsReset(domEditSelection); } }, - [domEditSelection, addGsapAnimation, currentTime, handleDomManualEditsReset], + [domEditSelection, addGsapAnimation, handleDomManualEditsReset], ); const handleGsapAddProperty = useCallback( @@ -155,6 +162,13 @@ export function useGsapSelectionHandlers({ [domEditSelection, addKeyframe], ); + const handleGsapAddKeyframeBatch = useCallback( + (animId: string, percentage: number, properties: Record) => { + if (!domEditSelection) return Promise.resolve(); + return addKeyframeBatch(domEditSelection, animId, percentage, properties); + }, + [domEditSelection, addKeyframeBatch], + ); const handleGsapRemoveKeyframe = useCallback( (animId: string, percentage: number) => { if (!domEditSelection) return; @@ -165,8 +179,8 @@ export function useGsapSelectionHandlers({ const handleGsapConvertToKeyframes = useCallback( (animId: string, resolvedFromValues?: Record) => { - if (!domEditSelection) return; - convertToKeyframes(domEditSelection, animId, resolvedFromValues); + if (!domEditSelection) return Promise.resolve(); + return convertToKeyframes(domEditSelection, animId, resolvedFromValues); }, [domEditSelection, convertToKeyframes], ); @@ -198,6 +212,7 @@ export function useGsapSelectionHandlers({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 4075c711c..1a3c1eeed 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -95,7 +95,12 @@ export function getAnimationsForElement( if (target.selector) matchers.add(target.selector); if (matchers.size === 0) return []; return animations.filter((a) => - a.targetSelector.split(",").some((part) => matchers.has(part.trim())), + a.targetSelector.split(",").some((part) => { + const trimmed = part.trim(); + if (matchers.has(trimmed)) return true; + const lastSimple = trimmed.split(/\s+/).pop(); + return lastSimple ? matchers.has(lastSimple) : false; + }), ); } @@ -251,6 +256,16 @@ export function useGsapAnimationsForElement( const elementId = target?.id ?? null; useEffect(() => { if (!elementId) return; + + // Resolve the element's time range from the player store so we can + // convert tween-relative keyframe percentages to clip-relative ones. + const { elements } = usePlayerStore.getState(); + const timelineEl = elements.find( + (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`, + ); + const elStart = timelineEl?.start ?? 0; + const elDuration = timelineEl?.duration ?? 4; + const allKeyframes: GsapKeyframesData["keyframes"] = []; let format: GsapKeyframesData["format"] = "percentage"; let ease: string | undefined; @@ -258,12 +273,29 @@ export function useGsapAnimationsForElement( for (const anim of animations) { const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim); if (!kf) continue; - allKeyframes.push(...kf.keyframes); + // Convert tween-relative percentages to clip-relative so diamonds + // render at the correct position within the timeline clip. + const tweenPos = typeof anim.position === "number" ? anim.position : 0; + const tweenDur = anim.duration ?? elDuration; + for (const k of kf.keyframes) { + const absTime = tweenPos + (k.percentage / 100) * tweenDur; + const clipPct = + elDuration > 0 + ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 + : k.percentage; + allKeyframes.push({ ...k, percentage: clipPct }); + } format = kf.format; if (kf.ease) ease = kf.ease; if (kf.easeEach) easeEach = kf.easeEach; } - if (allKeyframes.length === 0) return; + if (allKeyframes.length === 0) { + const { keyframeCache, setKeyframeCache } = usePlayerStore.getState(); + if (keyframeCache.has(`${sourceFile}#${elementId}`)) { + setKeyframeCache(`${sourceFile}#${elementId}`, undefined); + } + return; + } const dedupedKeyframes = deduplicateKeyframes(allKeyframes); const merged: GsapKeyframesData = { format, @@ -319,17 +351,34 @@ export function usePopulateKeyframeCacheForFile( setKeyframeCache(key, undefined); } } + const { elements } = usePlayerStore.getState(); const mergedByElement = new Map(); for (const anim of parsed.animations) { const id = extractIdFromSelector(anim.targetSelector); if (!id) continue; const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim); if (!kfData) continue; + // Convert tween-relative percentages to clip-relative. + const tweenPos = typeof anim.position === "number" ? anim.position : 0; + const tweenDur = anim.duration ?? 1; + const timelineEl = elements.find( + (el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`, + ); + const elStart = timelineEl?.start ?? 0; + const elDuration = timelineEl?.duration ?? 4; + const clipKeyframes = kfData.keyframes.map((kf) => { + const absTime = tweenPos + (kf.percentage / 100) * tweenDur; + const clipPct = + elDuration > 0 + ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 + : kf.percentage; + return { ...kf, percentage: clipPct }; + }); const existing = mergedByElement.get(id); if (existing) { - existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...kfData.keyframes]); + existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]); } else { - mergedByElement.set(id, { ...kfData, keyframes: [...kfData.keyframes] }); + mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes }); } } for (const [id, kfData] of mergedByElement) { diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts index 985025bbd..ab1d1c8a8 100644 --- a/packages/studio/src/hooks/useStudioContextValue.ts +++ b/packages/studio/src/hooks/useStudioContextValue.ts @@ -17,7 +17,6 @@ interface StudioContextInput { compositionLoading: boolean; refreshKey: number; setRefreshKey: React.Dispatch>; - currentTime: number; timelineElements: StudioContextValue["timelineElements"]; isPlaying: boolean; editHistory: { canUndo: boolean; canRedo: boolean; undoLabel: string; redoLabel: string }; @@ -50,7 +49,7 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex compositionLoading: input.compositionLoading, refreshKey: input.refreshKey, setRefreshKey: input.setRefreshKey, - currentTime: input.currentTime, + timelineElements: input.timelineElements, isPlaying: input.isPlaying, editHistory: input.editHistory, @@ -81,6 +80,7 @@ export function useInspectorState( rightCollapsed: boolean, isPlaying: boolean, domEditSelection: DomEditSelection | null, + isGestureRecording?: boolean, ): InspectorState { // fallow-ignore-next-line complexity return useMemo(() => { @@ -101,9 +101,10 @@ export function useInspectorState( inspectorPanelActive, inspectorButtonActive: STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive, - shouldShowSelectedDomBounds: inspectorPanelActive && !rightCollapsed && !isPlaying, + shouldShowSelectedDomBounds: + inspectorPanelActive && !rightCollapsed && !isPlaying && !isGestureRecording, }; - }, [rightPanelTab, rightCollapsed, isPlaying, domEditSelection]); + }, [rightPanelTab, rightCollapsed, isPlaying, domEditSelection, isGestureRecording]); } // fallow-ignore-next-line complexity diff --git a/packages/studio/src/hooks/useStudioUrlState.ts b/packages/studio/src/hooks/useStudioUrlState.ts index 5a8b9428f..6ab0b2f80 100644 --- a/packages/studio/src/hooks/useStudioUrlState.ts +++ b/packages/studio/src/hooks/useStudioUrlState.ts @@ -11,7 +11,6 @@ import { interface UseStudioUrlStateParams { projectId: string | null; activeCompPath: string | null; - currentTime: number; duration: number; isPlaying: boolean; compositionLoading: boolean; @@ -57,7 +56,6 @@ function replaceHash(nextHash: string) { export function useStudioUrlState({ projectId, activeCompPath, - currentTime, duration, isPlaying, compositionLoading, @@ -72,6 +70,7 @@ export function useStudioUrlState({ applyDomSelection, initialState, }: UseStudioUrlStateParams) { + const currentTime = usePlayerStore((s) => s.currentTime); const hydratedSeekRef = useRef(initialState.currentTime == null); const hydratedInitialTimeRef = useRef(initialState.currentTime == null); const hydratedSelectionRef = useRef(initialState.selection == null); diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index f2b7c032a..589b9b6f4 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -41,6 +41,7 @@ interface UseTimelineEditingOptions { previewIframeRef: React.RefObject; pendingTimelineEditPathRef: React.MutableRefObject>; uploadProjectFiles: (files: Iterable, dir?: string) => Promise; + isRecordingRef?: React.RefObject; } // ── Helpers ── @@ -174,6 +175,7 @@ export function useTimelineEditing({ previewIframeRef, pendingTimelineEditPathRef, uploadProjectFiles, + isRecordingRef, }: UseTimelineEditingOptions) { const projectIdRef = useRef(projectId); projectIdRef.current = projectId; @@ -187,6 +189,10 @@ export function useTimelineEditing({ label: string, buildPatches: PersistTimelineEditInput["buildPatches"], ): Promise => { + if (isRecordingRef?.current) { + showToast("Cannot edit timeline while recording", "error"); + return Promise.resolve(); + } const pid = projectIdRef.current; if (!pid) return Promise.resolve(); const queued = editQueueRef.current.then(() => @@ -213,6 +219,8 @@ export function useTimelineEditing({ writeProjectFile, domEditSaveTimestampRef, pendingTimelineEditPathRef, + showToast, + isRecordingRef, ], ); @@ -274,6 +282,10 @@ export function useTimelineEditing({ const handleTimelineElementDelete = useCallback( async (element: TimelineElement) => { + if (isRecordingRef?.current) { + showToast("Cannot edit timeline while recording", "error"); + return; + } const pid = projectIdRef.current; if (!pid) throw new Error("No active project"); const label = getTimelineElementLabel(element); @@ -338,6 +350,7 @@ export function useTimelineEditing({ writeProjectFile, domEditSaveTimestampRef, reloadPreview, + isRecordingRef, ], ); @@ -347,6 +360,10 @@ export function useTimelineEditing({ placement: Pick, durationOverride?: number, ) => { + if (isRecordingRef?.current) { + showToast("Cannot edit timeline while recording", "error"); + return; + } const pid = projectIdRef.current; if (!pid) throw new Error("No active project"); @@ -415,11 +432,16 @@ export function useTimelineEditing({ writeProjectFile, domEditSaveTimestampRef, reloadPreview, + isRecordingRef, ], ); const handleTimelineFileDrop = useCallback( async (files: File[], placement?: Pick) => { + if (isRecordingRef?.current) { + showToast("Cannot edit timeline while recording", "error"); + return; + } const pid = projectIdRef.current; if (!pid) return; const uploaded = await uploadProjectFiles(files); @@ -453,7 +475,14 @@ export function useTimelineEditing({ ); } }, - [activeCompPath, handleTimelineAssetDrop, timelineElements, uploadProjectFiles], + [ + activeCompPath, + handleTimelineAssetDrop, + timelineElements, + uploadProjectFiles, + isRecordingRef, + showToast, + ], ); const handleBlockedTimelineEdit = useCallback( @@ -468,6 +497,10 @@ export function useTimelineEditing({ const handleTimelineElementSplit = useCallback( async (element: TimelineElement, splitTime: number) => { + if (isRecordingRef?.current) { + showToast("Cannot edit timeline while recording", "error"); + return; + } const pid = projectIdRef.current; if (!pid) return; @@ -555,6 +588,7 @@ export function useTimelineEditing({ writeProjectFile, domEditSaveTimestampRef, reloadPreview, + isRecordingRef, ], ); diff --git a/packages/studio/src/player/components/ShortcutsPanel.tsx b/packages/studio/src/player/components/ShortcutsPanel.tsx index c5c201b3e..b91ef6398 100644 --- a/packages/studio/src/player/components/ShortcutsPanel.tsx +++ b/packages/studio/src/player/components/ShortcutsPanel.tsx @@ -27,6 +27,36 @@ const SHORTCUT_SECTIONS = [ { key: "R", label: "Record gesture" }, ], }, + { + title: "Editing", + hints: [ + { key: "⌘Z", label: "Undo" }, + { key: "⌘⇧Z", label: "Redo" }, + { key: "⌘C", label: "Copy element" }, + { key: "⌘V", label: "Paste element" }, + { key: "⌘X", label: "Cut element" }, + { key: "S", label: "Split clip at playhead" }, + { key: "Del", label: "Delete selected element" }, + ], + }, + { + title: "Gesture recording modifiers", + hints: [ + { key: "Drag", label: "Record x / y position" }, + { key: "Scroll", label: "Record z depth" }, + { key: "⇧ Drag", label: "Record rotationX / rotationY" }, + { key: "⌥ Drag", label: "Record rotation" }, + { key: "⌘ Drag↕", label: "Record opacity" }, + { key: "⌘ Scroll", label: "Record scale" }, + ], + }, + { + title: "Panels", + hints: [ + { key: "⌘1", label: "Compositions tab" }, + { key: "⌘2", label: "Assets tab" }, + ], + }, { title: "Work area", hints: [