diff --git a/docs/docs.json b/docs/docs.json index 3982833c10..c8a1caac63 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 0000000000..74be005978 --- /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 0a9888d327..8471cc925f 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 a4758d62ff..530ede9f8a 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 af9edf1317..480b1ca483 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 3404df15c9..6cc5005b76 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 ed68072a85..f965802df1 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 943c12bd4c..a0f2fa84d3 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 0000000000..133e11a299 --- /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 0000000000..a2a6abefc8 --- /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 a95996e391..21290390fc 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 a755824d50..c955c03d64 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 6e641dfc8b..90db5ef564 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 1029fd6dfb..a56043fbea 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 5140afc31e..000b26df3a 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 c65e87b88b..3c4f73793e 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 0000000000..86501341c3 --- /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 0000000000..9007ba2d94 --- /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 57d62c36ec..95e07374ec 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 79ca444ab4..a27147a9bf 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 502f19aff7..acad691e11 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 (