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.
This commit is contained in:
Miguel Ángel
2026-06-08 01:23:58 -04:00
parent 53f442c384
commit 280b2fe1e3
26 changed files with 1487 additions and 167 deletions
+1
View File
@@ -84,6 +84,7 @@
"guides/prompting",
"guides/hyperframes-vs-remotion",
"guides/gsap-animation",
"guides/keyframes",
"guides/rendering",
"guides/remove-background",
"guides/hdr",
+141
View File
@@ -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
<Note>
Keyframe diamonds are synthesized from your GSAP tweens automatically. Every `.to()`, `.from()`, and `.fromTo()` call produces start and end markers on the timeline.
</Note>
## 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
<Steps>
<Step title="Select an element">
Click an animated element in the preview or its clip in the timeline. The Design Panel opens on the right.
</Step>
<Step title="Edit property values">
Change any property value directly — for example, set Move X to `500` to make the element travel 500px. Changes apply immediately via soft reload.
</Step>
<Step title="Change the ease">
Click the ease dropdown (e.g., "Smooth ease") to pick a different easing function. The speed curve preview updates live.
</Step>
<Step title="Verify in Code tab">
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.
</Step>
</Steps>
## 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
<Steps>
<Step title="Select an element with x/y motion">
The element must have a `.to()` tween with both Move X and Move Y properties. Select it in the preview or timeline.
</Step>
<Step title="Toggle Arc Motion ON">
In the Animation section of the Design Panel, find the **Arc Motion** toggle below the property list. Switch it ON.
</Step>
<Step title="Adjust Curviness">
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.
</Step>
<Step title="Toggle Auto-Rotate (optional)">
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.
</Step>
<Step title="Verify the generated code">
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.
</Step>
<Step title="Disable to restore straight motion">
Toggle Arc Motion OFF to restore the original `x` and `y` properties as flat tween values.
</Step>
</Steps>
<Note>
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.
</Note>
## 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.
<Steps>
<Step title="Select an element">
Click the element you want to animate in the preview.
</Step>
<Step title="Click Record or press R">
In the Animation section of the Design Panel, click **Record gesture (R)** or press the R key. The timeline starts playing.
</Step>
<Step title="Drag the element">
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.
</Step>
<Step title="Stop recording">
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.
</Step>
<Step title="Review or undo">
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.
</Step>
</Steps>
## 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: <div>
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.
+18 -3
View File
@@ -1354,10 +1354,25 @@ export function addKeyframeToScript(
ease?: string,
backfillDefaults?: Record<string, number | string>,
): 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);
-15
View File
@@ -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<Element>();
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);
+96 -2
View File
@@ -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<ReturnType<typeof setInterval>>(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}
/>
)}
</div>
@@ -585,8 +663,24 @@ export function StudioApp() {
/>
)}
{gestureState === "recording" && previewIframe && (
<GestureTrailOverlay
samples={gestureRecording.samples}
canvasRect={(() => {
const r = previewIframe.getBoundingClientRect();
return { left: r.left, top: r.top, width: r.width, height: r.height };
})()}
mode="recording"
/>
)}
{dragOverlay.active && <StudioGlobalDragOverlay />}
{appToast && <StudioToast message={appToast.message} tone={appToast.tone} />}
{appToast && (
<StudioToast
message={appToast.message}
tone={appToast.tone}
onDismiss={dismissToast}
/>
)}
</div>
</DomEditProvider>
</FileManagerProvider>
@@ -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;
@@ -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 ? (
<MotionPanel
+47 -7
View File
@@ -1,18 +1,58 @@
interface StudioToastProps {
message: string;
tone?: "error" | "info";
onDismiss?: () => void;
}
export function StudioToast({ message, tone }: StudioToastProps) {
export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
const isError = tone === "error";
return (
<div
className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
tone === "error"
? "bg-red-900/90 border-red-700/50 text-red-200"
: "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
}`}
className="absolute bottom-6 right-6 z-[91] animate-in fade-in slide-in-from-bottom-2"
onClick={onDismiss}
role={onDismiss ? "button" : undefined}
style={onDismiss ? { cursor: "pointer" } : undefined}
>
{message}
<div
className="relative flex items-center gap-3 overflow-hidden rounded-2xl pl-4 pr-2 py-3 text-[12px]"
style={{
background: isError
? "linear-gradient(135deg, rgba(127,29,29,0.55), rgba(80,10,10,0.45))"
: "linear-gradient(135deg, rgba(38,38,38,0.55), rgba(23,23,23,0.45))",
backdropFilter: "blur(16px) saturate(1.6)",
WebkitBackdropFilter: "blur(16px) saturate(1.6)",
border: `1px solid ${isError ? "rgba(239,68,68,0.18)" : "rgba(255,255,255,0.08)"}`,
boxShadow: [
"0 8px 32px rgba(0,0,0,0.35)",
`inset 0 1px 0 ${isError ? "rgba(239,68,68,0.12)" : "rgba(255,255,255,0.06)"}`,
`inset 0 -1px 0 rgba(0,0,0,0.15)`,
].join(", "),
}}
>
<span className={isError ? "text-red-200" : "text-neutral-200"}>{message}</span>
{onDismiss && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDismiss();
}}
className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-300"
aria-label="Dismiss"
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M2 2l6 6M8 2l-6 6" />
</svg>
</button>
)}
</div>
</div>
);
}
@@ -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");
}
@@ -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<number, Record<string, number>>, eases: Map<number, string>) => void;
onDiscard: () => void;
onReRecord: () => void;
onSimplifiedChange?: (simplified: Map<number, Record<string, number>>) => 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<string>();
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 (
<div className="space-y-3 rounded-lg border border-neutral-800 bg-neutral-900/80 p-3">
<div className="flex items-center justify-between">
<span className="text-[12px] font-semibold text-panel-text-1">Gesture Recording</span>
<span className="text-[10px] text-panel-text-3">{keyframeCount} keyframes</span>
</div>
<div className="space-y-1.5">
<span className={LABEL}>Smoothing</span>
<SliderControl
value={epsilon}
min={0.5}
max={30}
step={0.5}
displayValue={epsilon.toFixed(1)}
formatDisplayValue={(v) => v.toFixed(1)}
onCommit={handleEpsilonChange}
/>
<div className="flex justify-between text-[9px] text-panel-text-4">
<span>Detailed</span>
<span>Smooth</span>
</div>
</div>
{propertyList.length > 0 && (
<div className="space-y-1">
<span className={LABEL}>Recorded properties</span>
<div className="flex flex-wrap gap-1">
{propertyList.map((prop) => (
<span
key={prop}
className="rounded bg-panel-input px-1.5 py-0.5 text-[10px] font-medium text-panel-text-2"
>
{prop}
</span>
))}
</div>
</div>
)}
{eases.size > 0 && (
<div className="space-y-1">
<span className={LABEL}>Inferred eases</span>
<div className="flex flex-wrap gap-1">
{Array.from(eases.entries())
.sort(([a], [b]) => a - b)
.map(([pct, ease]) => (
<span
key={pct}
className="rounded bg-panel-input px-1.5 py-0.5 text-[10px] text-panel-text-3"
>
{pct}% → {ease}
</span>
))}
</div>
</div>
)}
<div className="flex gap-2 pt-1">
<button
type="button"
onClick={handleCommit}
className="flex-1 rounded-md py-1.5 text-[11px] font-semibold transition-colors"
style={{ background: P.accent, color: "#09090B" }}
>
Commit
</button>
<button
type="button"
onClick={onReRecord}
className="flex-1 rounded-md border border-neutral-700 py-1.5 text-[11px] font-medium text-panel-text-2 transition-colors hover:bg-neutral-800"
>
Re-record
</button>
<button
type="button"
onClick={onDiscard}
className="rounded-md border border-neutral-700 px-3 py-1.5 text-[11px] font-medium text-red-400 transition-colors hover:bg-red-500/10"
>
Discard
</button>
</div>
</div>
);
});
@@ -0,0 +1,118 @@
import { memo, useMemo } from "react";
import type { GestureSample } from "../../hooks/useGestureRecording";
interface GestureTrailOverlayProps {
samples: GestureSample[];
simplifiedPoints?: Map<number, Record<string, number>>;
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 (
<svg
className="pointer-events-none absolute"
style={{
left: canvasRect.left,
top: canvasRect.top,
width: canvasRect.width,
height: canvasRect.height,
}}
viewBox={`0 0 ${canvasRect.width} ${canvasRect.height}`}
>
{mode === "recording" && trailPoints && (
<polyline
points={trailPoints}
fill="none"
stroke={accentColor}
strokeWidth="2"
strokeOpacity="0.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{mode === "preview" && (
<>
{trailPoints && (
<polyline
points={trailPoints}
fill="none"
stroke={accentColor}
strokeWidth="1"
strokeOpacity="0.2"
strokeDasharray="4 3"
strokeLinecap="round"
/>
)}
{simplifiedPath && (
<polyline
points={simplifiedPath}
fill="none"
stroke={accentColor}
strokeWidth="2"
strokeOpacity="0.8"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{diamondPositions.map((pt) => (
<g key={pt.pct} transform={`translate(${pt.x}, ${pt.y})`}>
<rect
x="-4"
y="-4"
width="8"
height="8"
rx="1"
transform="rotate(45)"
fill={accentColor}
fillOpacity="0.9"
/>
</g>
))}
</>
)}
</svg>
);
});
@@ -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<ReturnType<typeof setTimeout>>(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({
<div className="flex items-center gap-1">
<button
type="button"
onClick={onAskAgent}
className="flex h-6 w-6 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-800 hover:text-neutral-300"
title={copiedAgentPrompt ? "Prompt copied" : "Copy prompt to AI agent"}
onClick={() => {
const file = element.sourceFile ?? "index.html";
let lineNum: number | null = null;
try {
const src =
previewIframeRef?.current?.contentDocument?.documentElement?.outerHTML ?? "";
if (src && element.id) {
const idx = src.indexOf(`id="${element.id}"`);
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
}
if (!lineNum && element.selector) {
const tag = element.tagName.toLowerCase();
const cls = element.selector.startsWith(".")
? element.selector.slice(1).split(".")[0]
: null;
const search = cls ? `class="${cls}` : `<${tag}`;
const idx = src.indexOf(search);
if (idx > -1) lineNum = src.slice(0, idx).split("\n").length;
}
} catch {}
const fileLoc = lineNum ? `${file}:${lineNum}` : file;
const lines = [
`Element: ${element.label} (${sourceLabel})`,
`File: ${fileLoc}`,
`Position: x=${Math.round(element.boundingBox.x)}, y=${Math.round(element.boundingBox.y)}`,
`Size: ${Math.round(element.boundingBox.width)}×${Math.round(element.boundingBox.height)}`,
`Tag: <${element.tagName}>`,
];
if (
element.computedStyles["z-index"] &&
element.computedStyles["z-index"] !== "auto"
) {
lines.push(`Z-index: ${element.computedStyles["z-index"]}`);
}
if (gsapAnimations.length > 0) {
const anim = gsapAnimations[0];
lines.push(
`Animation: ${anim.method}() ${anim.duration}s at ${anim.position}s, ease: ${anim.ease ?? "default"}`,
);
const props = Object.entries(anim.properties)
.map(([k, v]) => `${k}: ${v}`)
.join(", ");
if (props) lines.push(`Properties: ${props}`);
}
const text = lines.join("\n");
void navigator.clipboard.writeText(text);
showToast(
`Copied element info for ${element.label} — paste into any AI agent`,
"info",
);
setClipboardCopied(true);
clearTimeout(clipboardTimerRef.current);
clipboardTimerRef.current = setTimeout(() => setClipboardCopied(false), 1500);
}}
className={`flex h-6 w-6 items-center justify-center rounded transition-colors ${
clipboardCopied
? "text-studio-accent"
: "text-neutral-500 hover:bg-neutral-800 hover:text-neutral-300"
}`}
title={clipboardCopied ? "Copied!" : "Copy element info to clipboard"}
>
<MessageSquare size={13} />
<svg
width="13"
height="13"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<rect x="5" y="5" width="9" height="9" rx="1.5" />
<path d="M11 5V3.5A1.5 1.5 0 009.5 2h-6A1.5 1.5 0 002 3.5v6A1.5 1.5 0 003.5 11H5" />
</svg>
</button>
<button
type="button"
@@ -566,6 +639,31 @@ export const PropertyPanel = memo(function PropertyPanel({
/>
)}
{onToggleRecording && (
<div className="px-4 pb-3">
<button
type="button"
onClick={onToggleRecording}
className={`w-full flex items-center justify-center gap-2 rounded-lg py-2 text-[11px] font-medium transition-colors ${
recordingState === "recording"
? "bg-red-500/15 text-red-400 border border-red-500/30 animate-pulse"
: "bg-panel-input text-panel-text-2 hover:bg-panel-hover border border-panel-border"
}`}
>
<svg width="10" height="10" viewBox="0 0 10 10">
{recordingState === "recording" ? (
<rect x="1" y="1" width="8" height="8" rx="1" fill="currentColor" />
) : (
<circle cx="5" cy="5" r="4.5" fill="currentColor" />
)}
</svg>
{recordingState === "recording"
? `Stop recording ${(recordingDuration ?? 0).toFixed(1)}s (R)`
: "Record gesture (R)"}
</button>
</div>
)}
{showEditableSections && (
<StyleSections
projectId={projectId}
@@ -68,6 +68,9 @@ export interface PropertyPanelProps {
value: number | string,
) => Promise<void>;
onSeekToTime?: (time: number) => void;
recordingState?: "idle" | "recording" | "preview";
recordingDuration?: number;
onToggleRecording?: () => void;
}
/* ------------------------------------------------------------------ */
@@ -369,15 +369,7 @@ export function StyleSections({
</div>
</Section>
<Section
title="Fill"
icon={<Palette size={15} />}
accessory={
<div className="rounded-full border border-neutral-700 bg-neutral-900 px-2.5 py-1 text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-400">
{preferredFillMode}
</div>
}
>
<Section title="Fill" icon={<Palette size={15} />}>
<div className="space-y-4">
<SegmentedControl
disabled={styleEditingDisabled}
@@ -142,53 +142,54 @@ export const RenderQueueItem = memo(function RenderQueueItem({
)}
</div>
{/* Actions */}
{hovered && (
<div className="flex items-center gap-1 flex-shrink-0">
{isComplete && (
<button
onClick={handleDownload}
className="p-1 rounded text-panel-text-4 hover:text-panel-accent transition-colors"
title="Download"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
)}
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-1 rounded text-panel-text-4 hover:text-red-400 transition-colors"
title="Remove"
{/* Actions — always visible to prevent layout shifts */}
<div className="flex items-center gap-1 flex-shrink-0">
<button
onClick={isComplete ? handleDownload : undefined}
className={`p-1 rounded transition-colors ${
isComplete
? "text-panel-text-5 hover:text-panel-accent"
: "text-panel-text-5/30 pointer-events-none"
}`}
title={isComplete ? "Download" : "Rendering..."}
disabled={!isComplete}
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
)}
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-1 rounded text-panel-text-5 hover:text-red-400 transition-colors"
title="Remove"
>
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
>
<path d="M18 6L6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
);
+2 -65
View File
@@ -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<void> {
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<void> {
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(
@@ -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<number>();
entry.keyframes = entry.keyframes.filter((kf) => {
if (seen.has(kf.percentage)) return false;
seen.add(kf.percentage);
return true;
});
}
return result;
}
@@ -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<Window | null>(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 ──
@@ -101,6 +101,8 @@ export interface UseDomEditCommitsParams {
projectIdRef: React.MutableRefObject<string | null>;
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(
@@ -0,0 +1,56 @@
import { useCallback } from "react";
import type { DomEditSelection } from "../components/editor/domEditing";
interface GestureCommitParams {
commitMutation: (
selection: DomEditSelection,
mutation: Record<string, unknown>,
options: { label: string; softReload: boolean },
) => Promise<void>;
}
export function useGestureCommit({ commitMutation }: GestureCommitParams) {
const commitGestureKeyframes = useCallback(
async (
selection: DomEditSelection,
animationId: string,
keyframes: Map<number, Record<string, number>>,
eases: Map<number, string>,
) => {
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 };
}
@@ -0,0 +1,245 @@
import { useCallback, useRef, useState } from "react";
// ── Types ──
export interface GestureSample {
time: number;
properties: Record<string, number>;
}
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<string, number>;
nextState: AccumulatedState;
} {
const properties: Record<string, number> = {};
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<GestureSample[]>([]);
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<Modifiers>({ shift: false, alt: false, meta: false });
const accumulatedRef = useRef<AccumulatedState>({ opacity: 1, scale: 1, z: 0 });
const rafIdRef = useRef(0);
const samplesRef = useRef<GestureSample[]>([]);
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,
};
}
@@ -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) {
+6 -1
View File
@@ -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 };
}
@@ -102,7 +102,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
const x2 = (kf.percentage / 100) * clipWidthPx;
return (
<div
key={`line-${prev.percentage}-${kf.percentage}`}
key={`line-${i}-${prev.percentage}-${kf.percentage}`}
className="absolute"
style={{
left: x1,
@@ -118,7 +118,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({
);
})}
{sorted.map((kf) => {
{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 (
<button
key={kf.percentage}
key={`${i}-${kf.percentage}`}
type="button"
className="absolute"
style={{
+202
View File
@@ -0,0 +1,202 @@
/**
* Velocity curve analyzer — matches gesture recording segments to GSAP ease presets.
*
* Pure utility: no React, no DOM, no Studio imports.
*/
const DEFAULT_EASE = "power2.inOut";
const SAMPLE_COUNT = 21; // 0, 0.05, 0.10, ..., 1.0
function buildReferenceCurve(fn: (t: number) => number): number[] {
const curve: number[] = [];
for (let i = 0; i < SAMPLE_COUNT; i++) {
curve.push(fn(i / (SAMPLE_COUNT - 1)));
}
return curve;
}
export const EASE_REFERENCE_CURVES: Record<string, number[]> = {
linear: buildReferenceCurve((t) => t),
"power1.out": buildReferenceCurve((t) => 1 - (1 - t) ** 2),
"power2.out": buildReferenceCurve((t) => 1 - (1 - t) ** 3),
"power3.out": buildReferenceCurve((t) => 1 - (1 - t) ** 4),
"power2.in": buildReferenceCurve((t) => t ** 3),
"power2.inOut": buildReferenceCurve((t) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2)),
"power3.inOut": buildReferenceCurve((t) => (t < 0.5 ? 8 * t ** 4 : 1 - (-2 * t + 2) ** 4 / 2)),
"back.out(1.7)": buildReferenceCurve((t) => {
const c1 = 1.7;
const c3 = c1 + 1;
return 1 + c3 * (t - 1) ** 3 + c1 * (t - 1) ** 2;
}),
"expo.out": buildReferenceCurve((t) => (t === 1 ? 1 : 1 - 2 ** (-10 * t))),
};
/**
* Linearly interpolate between two values.
*/
function lerp(a: number, b: number, frac: number): number {
return a + (b - a) * frac;
}
/**
* Resample an arbitrary set of (normalized) time/value pairs to SAMPLE_COUNT
* evenly-spaced points via linear interpolation.
*/
function resampleToGrid(normalized: Array<{ t: number; v: number }>): number[] {
const result: number[] = [];
for (let i = 0; i < SAMPLE_COUNT; i++) {
const target = i / (SAMPLE_COUNT - 1);
// Find the two surrounding samples
let lo = 0;
let hi = normalized.length - 1;
for (let j = 0; j < normalized.length - 1; j++) {
if (normalized[j].t <= target && normalized[j + 1].t >= target) {
lo = j;
hi = j + 1;
break;
}
}
const tLo = normalized[lo].t;
const tHi = normalized[hi].t;
const span = tHi - tLo;
const frac = span === 0 ? 0 : (target - tLo) / span;
result.push(lerp(normalized[lo].v, normalized[hi].v, frac));
}
return result;
}
/**
* Sum of squared differences between two same-length arrays.
*/
function ssd(a: number[], b: number[]): number {
let sum = 0;
for (let i = 0; i < a.length; i++) {
const d = a[i] - b[i];
sum += d * d;
}
return sum;
}
/**
* Infer the best-matching GSAP ease name for a segment of raw gesture samples.
*
* @param rawSamples - Time/value pairs from the gesture recording
* @param startTime - Segment start (inclusive)
* @param endTime - Segment end (inclusive)
* @returns GSAP ease name (e.g. "power2.out")
*/
export function inferSegmentEase(
rawSamples: Array<{ time: number; value: number }>,
startTime: number,
endTime: number,
): string {
const segment = rawSamples.filter((s) => s.time >= startTime && s.time <= endTime);
if (segment.length < 3) return DEFAULT_EASE;
const tMin = segment[0].time;
const tMax = segment[segment.length - 1].time;
const tRange = tMax - tMin;
const vMin = Math.min(...segment.map((s) => s.value));
const vMax = Math.max(...segment.map((s) => s.value));
const vRange = vMax - vMin;
// Degenerate segment — no meaningful motion
if (tRange === 0 || vRange === 0) return DEFAULT_EASE;
// Determine direction: if value decreases over time, flip the progress curve
const increasing = segment[segment.length - 1].value >= segment[0].value;
const normalized = segment.map((s) => ({
t: (s.time - tMin) / tRange,
v: increasing ? (s.value - vMin) / vRange : 1 - (s.value - vMin) / vRange,
}));
const resampled = resampleToGrid(normalized);
let bestEase = DEFAULT_EASE;
let bestScore = Infinity;
for (const [name, ref] of Object.entries(EASE_REFERENCE_CURVES)) {
const score = ssd(resampled, ref);
if (score < bestScore) {
bestScore = score;
bestEase = name;
}
}
return bestEase;
}
/**
* Infer eases for all segments between consecutive simplified percentages.
*
* @param rawSamples - Time/properties pairs from the gesture recording
* @param simplifiedPercentages - Sorted array of keyframe percentages (0-100)
* @param totalDuration - Total gesture duration in the same unit as rawSamples[].time
* @returns Map of percentage → ease name (ease applies to the segment ENDING at that percentage)
*/
export function inferAllEases(
rawSamples: Array<{ time: number; properties: Record<string, number> }>,
simplifiedPercentages: number[],
totalDuration: number,
): Map<number, string> {
const result = new Map<number, string>();
if (simplifiedPercentages.length < 2) return result;
for (let i = 1; i < simplifiedPercentages.length; i++) {
const pctStart = simplifiedPercentages[i - 1];
const pctEnd = simplifiedPercentages[i];
const startTime = (pctStart / 100) * totalDuration;
const endTime = (pctEnd / 100) * totalDuration;
// Find the primary moving property (largest absolute delta in this segment)
const segmentSamples = rawSamples.filter((s) => s.time >= startTime && s.time <= endTime);
if (segmentSamples.length < 2) {
result.set(pctEnd, DEFAULT_EASE);
continue;
}
const firstSample = segmentSamples[0];
const lastSample = segmentSamples[segmentSamples.length - 1];
const allProps = Object.keys(firstSample.properties);
let primaryProp = allProps[0];
let maxDelta = 0;
for (const prop of allProps) {
const delta = Math.abs(
(lastSample.properties[prop] ?? 0) - (firstSample.properties[prop] ?? 0),
);
if (delta > maxDelta) {
maxDelta = delta;
primaryProp = prop;
}
}
const flatSamples = segmentSamples.map((s) => ({
time: s.time,
value: s.properties[primaryProp] ?? 0,
}));
result.set(pctEnd, inferSegmentEase(flatSamples, startTime, endTime));
}
return result;
}
+183
View File
@@ -0,0 +1,183 @@
/**
* Ramer-Douglas-Peucker simplification for time-series data.
*
* Used to reduce gesture recording samples into a minimal set of keyframes
* that approximate the original curve within a configurable tolerance.
*/
// ---------------------------------------------------------------------------
// 1D time-series simplification
// ---------------------------------------------------------------------------
/**
* Perpendicular distance from point (t, v) to the line segment between
* (t1, v1) and (t2, v2). For 1D time-series this reduces to the vertical
* distance from the point to the interpolated value on the line.
*/
function perpendicularDistance(
t: number,
v: number,
t1: number,
v1: number,
t2: number,
v2: number,
): number {
// Degenerate case: start and end share the same time
if (t2 === t1) return Math.abs(v - v1);
const interpolated = v1 + ((v2 - v1) * (t - t1)) / (t2 - t1);
return Math.abs(v - interpolated);
}
/**
* Standard Ramer-Douglas-Peucker on 1D time-series data.
*
* Each point is treated as (time, value) in 2D space. Returns the minimal
* subset of input points that approximates the curve within `epsilon`.
*
* - `epsilon = 0` returns all points (no simplification).
* - A large `epsilon` returns just the first and last points.
* - Empty or single-point input is returned unchanged.
*/
export function simplifyTimeSeries(
points: Array<{ time: number; value: number }>,
epsilon: number,
): Array<{ time: number; value: number }> {
if (points.length <= 2) return points;
if (epsilon <= 0) return points;
const first = points[0];
const last = points[points.length - 1];
let maxDist = 0;
let maxIndex = 0;
for (let i = 1; i < points.length - 1; i++) {
const d = perpendicularDistance(
points[i].time,
points[i].value,
first.time,
first.value,
last.time,
last.value,
);
if (d > maxDist) {
maxDist = d;
maxIndex = i;
}
}
if (maxDist > epsilon) {
const left = simplifyTimeSeries(points.slice(0, maxIndex + 1), epsilon);
const right = simplifyTimeSeries(points.slice(maxIndex), epsilon);
// left includes maxIndex, right starts with maxIndex — drop the duplicate
return left.slice(0, -1).concat(right);
}
return [first, last];
}
// ---------------------------------------------------------------------------
// Multi-property gesture simplification
// ---------------------------------------------------------------------------
/**
* Simplify gesture recording samples into percentage-keyed keyframes.
*
* Runs `simplifyTimeSeries` independently per property across all samples,
* then merges the retained time points into a single Map keyed by percentage
* of `totalDuration` (0–100, rounded to 1 decimal).
*
* Independent per-property simplification means that complex motion on one
* property (e.g. `x`) does not force extra keyframes on a simpler property
* (e.g. `opacity`).
*
* At each retained percentage the output contains all properties interpolated
* at that time — not just the property that caused the time point to survive.
*/
export function simplifyGestureSamples(
samples: Array<{ time: number; properties: Record<string, number> }>,
totalDuration: number,
epsilon: number,
): Map<number, Record<string, number>> {
if (samples.length === 0) return new Map();
if (totalDuration <= 0) return new Map();
// Collect all property keys present across samples
const propertyKeys = new Set<string>();
for (const s of samples) {
for (const key of Object.keys(s.properties)) {
propertyKeys.add(key);
}
}
// Run RDP independently per property and collect surviving times
const survivingTimes = new Set<number>();
for (const key of propertyKeys) {
const series: Array<{ time: number; value: number }> = [];
for (const s of samples) {
if (key in s.properties) {
series.push({ time: s.time, value: s.properties[key] });
}
}
const simplified = simplifyTimeSeries(series, epsilon);
for (const pt of simplified) {
survivingTimes.add(pt.time);
}
}
// Sort surviving times so we can iterate in order
const sortedTimes = Array.from(survivingTimes).sort((a, b) => a - b);
// For each surviving time, interpolate all properties and store by percentage
const result = new Map<number, Record<string, number>>();
for (const t of sortedTimes) {
const pct = Math.round((t / totalDuration) * 1000) / 10; // 1 decimal
const props: Record<string, number> = {};
for (const key of propertyKeys) {
props[key] = interpolatePropertyAtTime(samples, key, t);
}
result.set(pct, props);
}
return result;
}
/**
* Linearly interpolate a single property value at the given time from the
* samples array. Assumes samples are sorted by time.
*/
function interpolatePropertyAtTime(
samples: Array<{ time: number; properties: Record<string, number> }>,
key: string,
t: number,
): number {
// Find bracketing samples that contain this property
let before: { time: number; value: number } | undefined;
let after: { time: number; value: number } | undefined;
for (const s of samples) {
if (!(key in s.properties)) continue;
const v = s.properties[key];
if (s.time <= t) {
before = { time: s.time, value: v };
}
if (s.time >= t && after === undefined) {
after = { time: s.time, value: v };
}
}
// Exact match or only one side available
if (before && before.time === t) return before.value;
if (after && after.time === t) return after.value;
if (!before) return after!.value;
if (!after) return before.value;
// Linear interpolation
const ratio = (t - before.time) / (after.time - before.time);
return before.value + (after.value - before.value) * ratio;
}