diff --git a/docs/docs.json b/docs/docs.json index 3982833c1..c8a1caac6 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -84,6 +84,7 @@ "guides/prompting", "guides/hyperframes-vs-remotion", "guides/gsap-animation", + "guides/keyframes", "guides/rendering", "guides/remove-background", "guides/hdr", diff --git a/docs/guides/keyframes.mdx b/docs/guides/keyframes.mdx new file mode 100644 index 000000000..74be00597 --- /dev/null +++ b/docs/guides/keyframes.mdx @@ -0,0 +1,141 @@ +--- +title: Keyframes & Arc Motion +description: "Edit GSAP keyframes visually in Studio — timeline diamonds, arc motion paths, and gesture recording." +--- + +Studio gives you visual tools to create and edit GSAP keyframes without writing code. You can adjust animation properties in the Design Panel, convert straight-line motion into curved arcs, and record gesture-based motion by dragging elements in the preview. + +## Timeline Keyframe Diamonds + +When you open a composition in Studio, the timeline shows **diamond markers** on clips that have GSAP animations. Each diamond represents a keyframe — a point in time where a property value is set. + +- **Start diamond** — where the tween begins (e.g., `x: 0`) +- **End diamond** — where the tween ends (e.g., `x: 1000`) +- Elements with multiple tweens show multiple diamond pairs + + + Keyframe diamonds are synthesized from your GSAP tweens automatically. Every `.to()`, `.from()`, and `.fromTo()` call produces start and end markers on the timeline. + + +## Editing Animation Properties + +Select any animated element in the preview or timeline to open the Design Panel. The **Animation** section shows: + +- **Method badge** — `Animate`, `Animate In`, or `Animate Out` (maps to `.to()`, `.from()`, `.fromTo()`) +- **Timing** — Length (duration) and Starts at (position on timeline) +- **Speed** — The GSAP ease (e.g., `power2.inOut`, `back.out(3)`) +- **Speed curve** — Visual preview of the easing function +- **Properties** — Each animated property (Move X, Move Y, Scale, Opacity, etc.) with its target value + + + + Click an animated element in the preview or its clip in the timeline. The Design Panel opens on the right. + + + Change any property value directly — for example, set Move X to `500` to make the element travel 500px. Changes apply immediately via soft reload. + + + Click the ease dropdown (e.g., "Smooth ease") to pick a different easing function. The speed curve preview updates live. + + + Switch to the Code tab to see the generated GSAP code. Every Design Panel edit writes valid GSAP that renders identically in preview and headless export. + + + +## Arc Motion + +Arc Motion converts a straight-line x/y animation into a curved path using GSAP's MotionPathPlugin. Instead of moving in a straight diagonal, the element follows a smooth arc — like tossing an object into a basket. + +### When to Use It + +Use Arc Motion when an element has both `x` and `y` properties in a single tween. Common examples: +- Add-to-cart animations (item arcs from product to cart icon) +- Throw/toss effects +- Any motion that should feel physical rather than robotic + +### Step-by-Step + + + + The element must have a `.to()` tween with both Move X and Move Y properties. Select it in the preview or timeline. + + + In the Animation section of the Design Panel, find the **Arc Motion** toggle below the property list. Switch it ON. + + + The **Curviness** slider controls how exaggerated the arc is: + - `0` — straight line (no curve) + - `1` — gentle natural arc + - `1.5–2.0` — smooth throw feel (recommended) + - `3.0` — extreme loop + + Scrub the timeline to preview the arc in real time. + + + Enable **Auto-Rotate** to make the element rotate to face the direction of travel along the arc. This adds a "thrown" feel vs. a "floating" feel. + + + Switch to the Code tab. You'll see: + + ```javascript + tl.to("#element", { + scale: 0.4, + opacity: 0, + duration: 1.0, + ease: "power2.inOut", + motionPath: { + path: [{x: 0, y: 0}, {x: 1400, y: -280}], + curviness: 1.5, + autoRotate: true + } + }, 1.0); + ``` + + The MotionPathPlugin CDN script is added automatically. + + + Toggle Arc Motion OFF to restore the original `x` and `y` properties as flat tween values. + + + + + Arc Motion works for flat `.to()` tweens with x/y properties. It synthesizes waypoints from `{x: 0, y: 0}` (start) to `{x: targetX, y: targetY}` (end). For more complex paths with intermediate waypoints, edit the `motionPath.path` array directly in the Code tab. + + +## Gesture Recording + +Record motion by physically dragging an element in the preview while the timeline plays. The pointer path is simplified and converted into GSAP keyframes automatically. + + + + Click the element you want to animate in the preview. + + + In the Animation section of the Design Panel, click **Record gesture (R)** or press the R key. The timeline starts playing. + + + Move the element in the preview by dragging it. Your pointer motion is sampled at ~60fps. A trail overlay shows the path you're drawing. + + + Press R again or wait for the timeline to reach the end. Recording stops, the motion is simplified (reducing ~180 raw samples to 5–15 clean keyframes), and the keyframes are written to the GSAP script immediately. + + + The timeline seeks back to the recording start so you can scrub through the result. If you don't like it, press **Cmd+Z** to undo and try again. + + + +## Clipboard Context + +The **clipboard icon** next to the element name in the Design Panel copies structured element context to your clipboard: + +``` +Element: Title (#title) +File: index.html:15 +Position: x=100, y=40 +Size: 264×43 +Tag:
+Animation: from() 0.5s at 0s, ease: power2.out +Properties: x: -40, opacity: 0 +``` + +Paste this into any AI agent prompt to give it spatial context about the element — its position, size, animation, and source location. diff --git a/packages/core/src/parsers/gsapParser.ts b/packages/core/src/parsers/gsapParser.ts index 0a9888d32..8471cc925 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -1354,10 +1354,25 @@ export function addKeyframeToScript( ease?: string, backfillDefaults?: Record, ): string { - const loc = locateAnimation(script, animationId); + let loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } if (!loc) return script; - const kfNode = findKeyframesObjectNode(loc.target.call.varsArg); - if (!kfNode) return script; + let kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + + if (!kfNode) { + script = convertToKeyframesInScript(script, animationId); + loc = locateAnimation(script, animationId); + if (!loc) { + const convertedId = animationId.replace(/-from-|-fromTo-/, "-to-"); + loc = locateAnimation(script, convertedId); + } + if (!loc) return script; + kfNode = findKeyframesObjectNode(loc.target.call.varsArg); + if (!kfNode) return script; + } const pctKey = `${percentage}%`; const newValueNode = buildKeyframeValueNode(properties, ease); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index a4758d62f..530ede9f8 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -1002,25 +1002,12 @@ export function initSandboxRuntimeModular(): void { }); // Stamp data-start / data-duration on GSAP-targeted elements that lack // them so the Studio timeline can discover individual animated elements. - // Skip elements whose ancestor already carries timing — stamping them - // would override the parent's clip visibility and cause preview/render - // parity drift. { const rootComp = resolveRootCompositionElement(); const rootDuration = boundDuration > 0 ? boundDuration : 0; const dur = String(rootDuration > 0 ? rootDuration : 1); const seen = new Set(); - const hasTimedAncestor = (el: HTMLElement): boolean => { - let cursor = el.parentElement; - while (cursor) { - if (cursor.hasAttribute("data-start")) return true; - if (cursor === rootComp) return false; - cursor = cursor.parentElement; - } - return false; - }; - // Stamp GSAP-targeted elements if (state.capturedTimeline.getChildren) { try { @@ -1030,7 +1017,6 @@ export function initSandboxRuntimeModular(): void { if (!(target instanceof HTMLElement)) continue; if (target === rootComp) continue; if (target.hasAttribute("data-start")) continue; - if (hasTimedAncestor(target)) continue; if (seen.has(target)) continue; seen.add(target); target.setAttribute("data-start", "0"); @@ -1050,7 +1036,6 @@ export function initSandboxRuntimeModular(): void { if (!(el instanceof HTMLElement)) continue; if (el === rootComp) continue; if (el.hasAttribute("data-start")) continue; - if (hasTimedAncestor(el)) continue; if (seen.has(el)) continue; if (el.tagName === "SCRIPT" || el.tagName === "STYLE" || el.tagName === "LINK") continue; seen.add(el); diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index 8cdc5ae05..a09f9baf7 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -195,7 +195,11 @@ export function patchElementInHtml( } break; case "text-content": - if (op.value != null) htmlEl.textContent = op.value; + if (op.value != null) { + const inner = htmlEl.children.length === 1 ? htmlEl.firstElementChild : null; + const textTarget = inner ? (inner as unknown as HTMLElement) : htmlEl; + textTarget.textContent = op.value; + } break; } } @@ -219,6 +223,35 @@ export interface SplitElementResult { newId: string | null; } +function resolveElementTiming(el: Element): { + start: number; + duration: number; + usesDataEnd: boolean; +} { + const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0; + const usesDataEnd = el.hasAttribute("data-end"); + const duration = usesDataEnd + ? parseFloat(el.getAttribute("data-end") ?? "") - start || 0 + : parseFloat(el.getAttribute("data-duration") ?? "0") || 0; + return { start, duration, usesDataEnd }; +} + +function setElementDuration( + el: Element, + start: number, + duration: number, + usesDataEnd: boolean, +): void { + if (usesDataEnd) { + const endTime = String(Math.round((start + duration) * 1000) / 1000); + el.setAttribute("data-end", endTime); + el.removeAttribute("data-duration"); + } else { + el.setAttribute("data-duration", String(Math.round(duration * 1000) / 1000)); + el.removeAttribute("data-end"); + } +} + export function splitElementInHtml( source: string, target: SourceMutationTarget, @@ -229,8 +262,7 @@ export function splitElementInHtml( const el = findTargetElement(document, target); if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null }; - const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0; - const duration = parseFloat(el.getAttribute("data-duration") ?? "0") || 0; + const { start, duration, usesDataEnd } = resolveElementTiming(el); if (duration <= 0 || splitTime <= start || splitTime >= start + duration) { return { html: source, matched: false, newId: null }; } @@ -241,7 +273,7 @@ export function splitElementInHtml( const clone = el.cloneNode(true) as HTMLElement; clone.setAttribute("id", newId); clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000)); - clone.setAttribute("data-duration", String(Math.round(secondDuration * 1000) / 1000)); + setElementDuration(clone, splitTime, secondDuration, usesDataEnd); // Adjust media trim offset for the second half const playbackStartAttr = el.hasAttribute("data-playback-start") @@ -251,7 +283,8 @@ export function splitElementInHtml( : null; if (playbackStartAttr) { const currentTrim = parseFloat(el.getAttribute(playbackStartAttr) ?? "0") || 0; - const rate = parseFloat(el.getAttribute("data-playback-rate") ?? "1") || 1; + const rateRaw = parseFloat(el.getAttribute("data-playback-rate") ?? ""); + const rate = Number.isFinite(rateRaw) ? rateRaw : 1; clone.setAttribute( playbackStartAttr, String(Math.round((currentTrim + firstDuration * rate) * 1000) / 1000), @@ -259,7 +292,7 @@ export function splitElementInHtml( } // Trim the original element's duration - el.setAttribute("data-duration", String(Math.round(firstDuration * 1000) / 1000)); + setElementDuration(el, start, firstDuration, usesDataEnd); // Insert clone after original if (el.nextSibling) { diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index af9edf131..6f8d897c0 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -35,6 +35,10 @@ 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 { fetchParsedAnimations, getAnimationsForElement } from "./hooks/useGsapTweenCache"; +import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay"; import { StudioLeftSidebar } from "./components/StudioLeftSidebar"; import { StudioPreviewArea } from "./components/StudioPreviewArea"; import { StudioRightPanel } from "./components/StudioRightPanel"; @@ -128,7 +132,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,6 +310,7 @@ export function StudioApp() { onResetKeyframes: () => resetKeyframesRef.current(), onDeleteSelectedKeyframes: () => deleteSelectedKeyframesRef.current(), onAfterUndoRedo: () => invalidateGsapCacheRef.current(), + onToggleRecording: () => handleToggleRecordingRef.current(), }); const selectSidebarTabStable = useCallback( (tab: SidebarTab) => leftSidebarRef.current?.selectTab(tab), @@ -400,6 +405,138 @@ 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 commitInFlightRef = useRef(false); + const handleToggleRecordingRef = useRef<() => void>(() => {}); + const domEditSessionRef = useRef(domEditSession); + domEditSessionRef.current = domEditSession; + + // Unmount: clear auto-stop interval + useEffect(() => () => clearInterval(recordingAutoStopRef.current), []); + + // fallow-ignore-next-line complexity + const stopAndCommitRecording = useCallback(async () => { + if (commitInFlightRef.current) return; + commitInFlightRef.current = true; + clearInterval(recordingAutoStopRef.current); + const frozenSamples = gestureRecording.stopRecording(); + const store = usePlayerStore.getState(); + store.setIsPlaying(false); + try { + const liveSession = domEditSessionRef.current; + const sel = liveSession.domEditSelection; + const selId = sel?.id; + let anims = liveSession.selectedGsapAnimations; + if (anims.length === 0 && sel) { + const projectId = window.location.hash.match(/project\/([^?/]+)/)?.[1]; + if (projectId) { + try { + const parsed = await fetchParsedAnimations(projectId, sel.sourceFile || "index.html"); + if (parsed) + anims = getAnimationsForElement(parsed.animations, { + id: sel.id, + selector: sel.selector, + }); + } catch { + /* fetch failed — proceed with empty anims */ + } + } + } + // Bail if selection changed during async fetch + if (selId && domEditSessionRef.current.domEditSelection?.id !== selId) return; + + const anim = anims?.[0]; + const duration = frozenSamples.length > 0 ? frozenSamples[frozenSamples.length - 1]!.time : 0; + console.log("[GR-COMMIT]", { samples: frozenSamples.length, animId: anim?.id, hasKf: !!anim?.keyframes, duration, hasBatch: !!liveSession.handleGsapAddKeyframeBatch, hasCommit: !!liveSession.commitMutation }); + + if (sel && frozenSamples.length > 2 && duration > 0) { + const simplified = simplifyGestureSamples(frozenSamples, duration, 5); + const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b); + + if (anim) { + if (!anim.keyframes) { + await liveSession.handleGsapConvertToKeyframes(anim.id); + } + for (const pct of sortedPcts) { + const props = simplified.get(pct); + if (!props) continue; + if (liveSession.handleGsapAddKeyframeBatch) { + await liveSession.handleGsapAddKeyframeBatch(anim.id, pct, props); + } + } + } else if (liveSession.commitMutation) { + const selector = sel.id ? `#${sel.id}` : sel.selector; + if (selector) { + const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; + const keyframes = sortedPcts.map((pct) => ({ + percentage: pct, + properties: simplified.get(pct) as Record, + })); + await liveSession.commitMutation( + { + type: "add-with-keyframes", + targetSelector: selector, + position: Math.round(elStart * 1000) / 1000, + duration: Math.round(elDuration * 1000) / 1000, + keyframes, + }, + { label: "Gesture recording", softReload: true }, + ); + try { + const { clearStudioPathOffset } = await import( + "./components/editor/manualEdits" + ); + clearStudioPathOffset(sel.element); + } catch { + /* non-critical */ + } + } + } + showToast(`Recorded ${sortedPcts.length} keyframes`, "info"); + } else if (frozenSamples.length <= 2) { + showToast("No gesture detected — move the pointer while recording", "error"); + } + } finally { + store.requestSeek(recordingStartTimeRef.current); + gestureRecording.clearSamples(); + setGestureState("idle"); + commitInFlightRef.current = false; + } + }, [gestureRecording, showToast]); + + const handleToggleRecording = useCallback(() => { + if (gestureState === "recording") { + stopAndCommitRecording(); + return; + } + const sel = domEditSessionRef.current.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); + 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, showToast, stopAndCommitRecording]); + handleToggleRecordingRef.current = handleToggleRecording; + const handlePreviewIframeRef = useCallback( (iframe: HTMLIFrameElement | null) => { previewIframeRef.current = iframe; @@ -435,6 +572,7 @@ export function StudioApp() { panelLayout.rightCollapsed, isPlaying, domEditSession.domEditSelection, + gestureState === "recording", ); useStudioUrlState({ @@ -541,6 +679,7 @@ export function StudioApp() { setCompIdToSrc={setCompIdToSrc} setCompositionLoading={setCompositionLoading} shouldShowSelectedDomBounds={shouldShowSelectedDomBounds} + isGestureRecording={gestureState === "recording"} blockPreview={blockPreview} /> @@ -554,6 +693,9 @@ export function StudioApp() { setActiveBlockParams(null); panelLayout.setRightPanelTab("design"); }} + recordingState={gestureState} + recordingDuration={gestureRecording.recordingDuration} + onToggleRecording={handleToggleRecording} /> )}
@@ -585,8 +727,27 @@ export function StudioApp() { /> )} + {gestureState === "recording" && previewIframe && ( + { + const r = previewIframe.getBoundingClientRect(); + return { left: r.left, top: r.top, width: r.width, height: r.height }; + })()} + compositionSize={compositionDimensions ?? undefined} + mode="recording" + /> + )} {dragOverlay.active && } - {appToast && } + {appToast && ( + + )} diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index 3404df15c..6cc5005b7 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -10,6 +10,8 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext"; import { useDomEditContext } from "../contexts/DomEditContext"; import { trackStudioEvent } from "../utils/studioTelemetry"; +export type RecordingState = "idle" | "recording" | "preview"; + export interface StudioHeaderProps { captureFrameHref: string; captureFrameFilename: string; diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 5399d3f42..45e52711d 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -56,6 +56,7 @@ export interface StudioPreviewAreaProps { setCompositionLoading: (loading: boolean) => void; shouldShowSelectedDomBounds: boolean; blockPreview?: BlockPreviewInfo | null; + isGestureRecording?: boolean; } // fallow-ignore-next-line complexity @@ -74,6 +75,7 @@ export function StudioPreviewArea({ setCompIdToSrc, setCompositionLoading, shouldShowSelectedDomBounds, + isGestureRecording, blockPreview, }: StudioPreviewAreaProps) { const { @@ -112,6 +114,7 @@ export function StudioPreviewArea({ handleGsapAddKeyframe, handleGsapConvertToKeyframes, handleGsapDeleteAnimation, + handleGsapRemoveAllKeyframes, } = useDomEditContext(); const [snapPrefs, setSnapPrefs] = useState(() => { @@ -144,9 +147,13 @@ export function StudioPreviewArea({ onSplitElement={handleTimelineElementSplit} onSelectTimelineElement={handleTimelineElementSelect} onDeleteAllKeyframes={(_elId) => { - const anim = - selectedGsapAnimations.find((a) => a.keyframes) ?? selectedGsapAnimations[0]; - if (anim) handleGsapDeleteAnimation(anim.id); + const kfAnim = selectedGsapAnimations.find((a) => a.keyframes); + if (kfAnim) { + handleGsapRemoveAllKeyframes(kfAnim.id); + } else { + const anyAnim = selectedGsapAnimations[0]; + if (anyAnim) handleGsapDeleteAnimation(anyAnim.id); + } }} onDeleteKeyframe={(_elId, pct) => { const anim = selectedGsapAnimations.find((a) => a.keyframes); @@ -241,7 +248,7 @@ export function StudioPreviewArea({ } selection={shouldShowSelectedDomBounds ? domEditSelection : null} groupSelections={shouldShowSelectedDomBounds ? domEditGroupSelections : []} - allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED} + allowCanvasMovement={STUDIO_PREVIEW_MANUAL_EDITING_ENABLED && !isGestureRecording} onCanvasMouseDown={handlePreviewCanvasMouseDown} onCanvasPointerMove={handlePreviewCanvasPointerMove} onCanvasPointerLeave={handlePreviewCanvasPointerLeave} diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index ed68072a8..f965802df 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -32,6 +32,9 @@ export interface StudioRightPanelProps { compositionPath: string; } | null; onCloseBlockParams?: () => void; + recordingState?: "idle" | "recording" | "preview"; + recordingDuration?: number; + onToggleRecording?: () => void; } // fallow-ignore-next-line complexity @@ -41,6 +44,9 @@ export function StudioRightPanel({ motionPanelActive, activeBlockParams, onCloseBlockParams, + recordingState, + recordingDuration, + onToggleRecording, }: StudioRightPanelProps) { const { rightWidth, @@ -230,6 +236,9 @@ export function StudioRightPanel({ onCommitAnimatedProperty={commitAnimatedProperty} onSetArcPath={handleSetArcPath} onUpdateArcSegment={handleUpdateArcSegment} + recordingState={recordingState} + recordingDuration={recordingDuration} + onToggleRecording={onToggleRecording} /> ) : motionPanelActive ? ( void; } -export function StudioToast({ message, tone }: StudioToastProps) { +export function StudioToast({ message, tone, onDismiss }: StudioToastProps) { + const isError = tone === "error"; return (
- {message} +
+ {message} + {onDismiss && ( + + )} +
); } diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 943c12bd4..abbb471bf 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -1,3 +1,5 @@ +import { useRef } from "react"; +import { useEnableKeyframes, type EnableKeyframesSession } from "../hooks/useEnableKeyframes"; import { getNextTimelineZoomPercent, getTimelineZoomPercent, @@ -7,123 +9,12 @@ import { usePlayerStore, type TimelineElement } from "../player"; import { STUDIO_KEYFRAMES_ENABLED } from "./editor/manualEditingAvailability"; import { Tooltip } from "./ui"; import { Scissors } from "../icons/SystemIcons"; -import type { GsapAnimation, GsapPercentageKeyframe } from "@hyperframes/core/gsap-parser"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "./editor/domEditingTypes"; -function interpolateKeyframeProperties( - keyframes: GsapPercentageKeyframe[], - pct: number, -): Record { - const sorted = keyframes.slice().sort((a, b) => a.percentage - b.percentage); - const allProps = new Set(); - for (const kf of sorted) { - for (const p of Object.keys(kf.properties)) { - if (typeof kf.properties[p] === "number") allProps.add(p); - } - } - const result: Record = {}; - for (const prop of allProps) { - let prev: { pct: number; val: number } | null = null; - let next: { pct: number; val: number } | null = null; - for (const kf of sorted) { - const v = kf.properties[prop]; - if (typeof v !== "number") continue; - if (kf.percentage <= pct) prev = { pct: kf.percentage, val: v }; - if (kf.percentage >= pct && !next) next = { pct: kf.percentage, val: v }; - } - if (prev && next && prev.pct !== next.pct) { - const t = (pct - prev.pct) / (next.pct - prev.pct); - result[prop] = Math.round(prev.val + t * (next.val - prev.val)); - } else if (prev) { - result[prop] = Math.round(prev.val); - } else if (next) { - result[prop] = Math.round(next.val); - } - } - return result; -} - -function readRuntimeKeyframeValues( - iframe: HTMLIFrameElement | null, - sel: DomEditSelection, - keyframes: GsapPercentageKeyframe[], -): Record { - if (!iframe?.contentWindow) return {}; - let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined; - try { - gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap; - } catch { - return {}; - } - if (!gsap?.getProperty) return {}; - const selector = sel.id ? `#${sel.id}` : sel.selector; - if (!selector) return {}; - let doc: Document | null = null; - try { - doc = iframe.contentDocument; - } catch { - return {}; - } - const element = doc?.querySelector(selector); - if (!element) return {}; - const allProps = new Set(); - for (const kf of keyframes) { - for (const p of Object.keys(kf.properties)) { - if (typeof kf.properties[p] === "number") allProps.add(p); - } - } - const result: Record = {}; - for (const prop of allProps) { - const val = Number(gsap.getProperty(element, prop)); - if (Number.isFinite(val)) result[prop] = Math.round(val); - } - return result; -} - -// fallow-ignore-next-line complexity -function readRuntimeValuesForAnim( - iframe: HTMLIFrameElement | null, - sel: DomEditSelection, - anim: GsapAnimation, -): Record { - if (!iframe?.contentWindow) return {}; - let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined; - try { - gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap; - } catch { - return {}; - } - if (!gsap?.getProperty) return {}; - const selector = sel.id ? `#${sel.id}` : sel.selector; - if (!selector) return {}; - let doc: Document | null = null; - try { - doc = iframe.contentDocument; - } catch { - return {}; - } - const element = doc?.querySelector(selector); - if (!element) return {}; - const result: Record = {}; - for (const prop of Object.keys(anim.properties)) { - const val = Number(gsap.getProperty(element, prop)); - if (Number.isFinite(val)) result[prop] = Math.round(val); - } - return result; -} - -interface DomEditSessionSlice { +interface DomEditSessionSlice extends EnableKeyframesSession { domEditSelection: DomEditSelection | null; selectedGsapAnimations: GsapAnimation[]; - handleGsapRemoveKeyframe: (animId: string, pct: number) => void; - handleGsapAddKeyframe: (animId: string, pct: number, prop: string, val: number | string) => void; - handleGsapConvertToKeyframes: ( - animId: string, - resolvedFromValues?: Record, - ) => void; - handleGsapMaterializeKeyframes?: (animId: string) => Promise; - handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void; - previewIframeRef?: React.RefObject; } interface TimelineToolbarProps { @@ -132,15 +23,20 @@ interface TimelineToolbarProps { onSplitElement?: (element: TimelineElement, splitTime: number) => void; } -// fallow-ignore-next-line complexity function useKeyframeToggle(session?: DomEditSessionSlice) { const currentTime = usePlayerStore((s) => s.currentTime); + const sessionRef = useRef(session); + sessionRef.current = session; + + const onToggle = useEnableKeyframes( + sessionRef as React.RefObject, + ); + if (!session) return { state: "none" as const, onToggle: undefined }; const sel = session.domEditSelection; const anims = session.selectedGsapAnimations; const kfAnim = anims.find((a) => a.keyframes); - const flatAnim = anims.find((a) => !a.keyframes); let state: "active" | "inactive" | "none" = "none"; if (kfAnim?.keyframes && sel) { @@ -155,56 +51,7 @@ function useKeyframeToggle(session?: DomEditSessionSlice) { : "inactive"; } - // fallow-ignore-next-line complexity - const onToggle = sel - ? async () => { - const t = usePlayerStore.getState().currentTime; - if (kfAnim?.keyframes) { - if (kfAnim.hasUnresolvedKeyframes) { - await session.handleGsapMaterializeKeyframes?.(kfAnim.id); - } - 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; - const existing = kfAnim.keyframes.keyframes.find( - (k) => Math.abs(k.percentage - pct) <= 1, - ); - if (existing) { - session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage); - } else { - const runtimeValues = readRuntimeKeyframeValues( - session.previewIframeRef?.current ?? null, - sel, - kfAnim.keyframes.keyframes, - ); - const values = - Object.keys(runtimeValues).length > 0 - ? runtimeValues - : interpolateKeyframeProperties(kfAnim.keyframes.keyframes, pct); - for (const [prop, val] of Object.entries(values)) { - session.handleGsapAddKeyframe(kfAnim.id, pct, prop, val); - } - } - } else if (flatAnim) { - const runtimeProps = readRuntimeValuesForAnim( - session.previewIframeRef?.current ?? null, - sel, - flatAnim, - ); - session.handleGsapConvertToKeyframes( - flatAnim.id, - Object.keys(runtimeProps).length > 0 ? runtimeProps : undefined, - ); - } else { - session.handleGsapAddAnimation("to"); - } - } - : undefined; - - return { state, onToggle }; + return { state, onToggle: sel ? onToggle : undefined }; } export function TimelineToolbar({ diff --git a/packages/studio/src/components/editor/GesturePreviewPanel.tsx b/packages/studio/src/components/editor/GesturePreviewPanel.tsx new file mode 100644 index 000000000..cd0514875 --- /dev/null +++ b/packages/studio/src/components/editor/GesturePreviewPanel.tsx @@ -0,0 +1,150 @@ +import { memo, useCallback, useEffect, 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( + () => simplifyGestureSamples(samples, totalDuration, epsilon), + [samples, totalDuration, epsilon], + ); + + useEffect(() => { + onSimplifiedChange?.(simplified); + }, [simplified, onSimplifiedChange]); + + const eases = useMemo(() => { + const pcts = Array.from(simplified.keys()).sort((a, b) => a - b); + return inferAllEases( + samples.map((s) => ({ + time: s.time, + properties: s.properties, + })), + pcts, + totalDuration, + ); + }, [samples, simplified, totalDuration]); + + const keyframeCount = simplified.size; + const propertyList = useMemo(() => { + const keys = new Set(); + for (const props of simplified.values()) { + for (const k of Object.keys(props)) keys.add(k); + } + return Array.from(keys); + }, [simplified]); + + const handleEpsilonChange = useCallback((v: number) => { + setEpsilon(Math.round(v * 10) / 10); + }, []); + + const handleCommit = useCallback(() => { + onCommit(simplified, eases); + }, [onCommit, simplified, eases]); + + return ( +
+
+ Gesture Recording + {keyframeCount} keyframes +
+ +
+ Smoothing + v.toFixed(1)} + onCommit={handleEpsilonChange} + /> +
+ Detailed + Smooth +
+
+ + {propertyList.length > 0 && ( +
+ Recorded properties +
+ {propertyList.map((prop) => ( + + {prop} + + ))} +
+
+ )} + + {eases.size > 0 && ( +
+ Inferred eases +
+ {Array.from(eases.entries()) + .sort(([a], [b]) => a - b) + .map(([pct, ease]) => ( + + {pct}% → {ease} + + ))} +
+
+ )} + +
+ + + +
+
+ ); +}); diff --git a/packages/studio/src/components/editor/GestureTrailOverlay.tsx b/packages/studio/src/components/editor/GestureTrailOverlay.tsx new file mode 100644 index 000000000..f067178b4 --- /dev/null +++ b/packages/studio/src/components/editor/GestureTrailOverlay.tsx @@ -0,0 +1,128 @@ +import { memo, useMemo } from "react"; +import type { GestureSample } from "../../hooks/useGestureRecording"; + +interface GestureTrailOverlayProps { + samples: GestureSample[]; + sampleCount?: number; + trail?: Array<{ x: number; y: number }>; + simplifiedPoints?: Map>; + canvasRect: { left: number; top: number; width: number; height: number }; + compositionSize?: { width: number; height: number }; + mode: "recording" | "preview"; + accentColor?: string; +} + +export const GestureTrailOverlay = memo(function GestureTrailOverlay({ + samples, + sampleCount, + trail, + simplifiedPoints, + canvasRect, + compositionSize, + mode, + accentColor = "#3CE6AC", +}: GestureTrailOverlayProps) { + const trailPoints = useMemo(() => { + if (trail && trail.length > 1) { + return trail.map((p) => `${p.x - canvasRect.left},${p.y - canvasRect.top}`).join(" "); + } + 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(" "); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [samples, trail, sampleCount, canvasRect.left, canvasRect.top]); + + 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 ( + 0 ? `0 0 ${canvasRect.width} ${canvasRect.height}` : `0 0 ${compositionSize?.width ?? canvasRect.width} ${compositionSize?.height ?? canvasRect.height}`} + > + {mode === "recording" && trailPoints && ( + + )} + + {mode === "preview" && ( + <> + {trailPoints && ( + + )} + {simplifiedPath && ( + + )} + {diamondPositions.map((pt) => ( + + + + ))} + + )} + + ); +}); diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index a95996e39..fea818d25 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 && ( ; diff --git a/packages/studio/src/components/editor/propertyPanelHelpers.ts b/packages/studio/src/components/editor/propertyPanelHelpers.ts index f5e205a76..c3029fffa 100644 --- a/packages/studio/src/components/editor/propertyPanelHelpers.ts +++ b/packages/studio/src/components/editor/propertyPanelHelpers.ts @@ -68,6 +68,9 @@ export interface PropertyPanelProps { value: number | string, ) => Promise; onSeekToTime?: (time: number) => void; + recordingState?: "idle" | "recording" | "preview"; + recordingDuration?: number; + onToggleRecording?: () => void; } /* ------------------------------------------------------------------ */ diff --git a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx index a755824d5..c955c03d6 100644 --- a/packages/studio/src/components/editor/propertyPanelStyleSections.tsx +++ b/packages/studio/src/components/editor/propertyPanelStyleSections.tsx @@ -369,15 +369,7 @@ export function StyleSections({ -
} - accessory={ -
- {preferredFillMode} -
- } - > +
}>
{ frame = requestAnimationFrame(update); - if (rafPausedRef.current) return; + if (rafPausedRef.current) { + if (childRectsRef.current.length > 0) { + childRectsRef.current = []; + setChildRectsState([]); + } + return; + } const sel = selectionRef.current; const iframe = iframeRef.current; diff --git a/packages/studio/src/components/renders/RenderQueueItem.tsx b/packages/studio/src/components/renders/RenderQueueItem.tsx index 1005328f2..5c4377947 100644 --- a/packages/studio/src/components/renders/RenderQueueItem.tsx +++ b/packages/studio/src/components/renders/RenderQueueItem.tsx @@ -142,53 +142,54 @@ export const RenderQueueItem = memo(function RenderQueueItem({ )}
- {/* Actions */} - {hovered && ( -
- {isComplete && ( - - )} - -
- )} + + + + + + + ); diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 985cbc57b..c62fda6b4 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -67,6 +67,7 @@ export function DomEditProvider({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, @@ -76,6 +77,7 @@ export function DomEditProvider({ handleUpdateArcSegment, invalidateGsapCache, previewIframeRef, + commitMutation, }, children, }: { @@ -138,6 +140,7 @@ export function DomEditProvider({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, @@ -147,6 +150,7 @@ export function DomEditProvider({ handleUpdateArcSegment, invalidateGsapCache, previewIframeRef, + commitMutation, }), [ domEditSelection, @@ -203,6 +207,7 @@ export function DomEditProvider({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, @@ -212,6 +217,7 @@ export function DomEditProvider({ handleUpdateArcSegment, invalidateGsapCache, previewIframeRef, + commitMutation, ], ); return {children}; diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 6e641dfc8..10d9ef332 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -261,13 +261,21 @@ 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 if (anim.method === "from" || anim.method === "fromTo") { + // from()/fromTo() — convert to keyframes in a single mutation, placing + // the dragged position at the 100% (rest) keyframe. A single mutation + // avoids the stable-id flip (from→to) that breaks chained mutations. + await callbacks.commitMutation( + selection, + { + type: "convert-to-keyframes", + animationId: anim.id, + resolvedFromValues: { x: newX, y: newY }, + }, + { label: "Move layer (keyframe rest)", softReload: true, beforeReload: 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()/set() — convert to keyframes then add at current percentage. const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); await commitFlatViaKeyframes( selection, @@ -334,65 +342,6 @@ async function commitFlatViaKeyframes( ); } -async function commitFromPosition( - selection: DomEditSelection, - anim: GsapAnimation, - delta: { x: number; y: number }, - callbacks: GsapDragCommitCallbacks, - beforeReload: () => void, -): Promise { - const fromX = Math.round(Number(anim.properties.x ?? 0) + delta.x); - const fromY = Math.round(Number(anim.properties.y ?? 0) + delta.y); - - await callbacks.commitMutation( - selection, - { type: "update-property", animationId: anim.id, property: "x", value: fromX }, - { label: "Move layer (GSAP from x)", skipReload: true }, - ); - await callbacks.commitMutation( - selection, - { type: "update-property", animationId: anim.id, property: "y", value: fromY }, - { label: "Move layer (GSAP from y)", softReload: true, beforeReload }, - ); -} - -// fallow-ignore-next-line complexity -async function commitFromToPosition( - selection: DomEditSelection, - anim: GsapAnimation, - delta: { x: number; y: number }, - callbacks: GsapDragCommitCallbacks, - beforeReload: () => void, -): Promise { - if (anim.fromProperties) { - const fromX = Math.round(Number(anim.fromProperties.x ?? 0) + delta.x); - const fromY = Math.round(Number(anim.fromProperties.y ?? 0) + delta.y); - await callbacks.commitMutation( - selection, - { type: "update-from-property", animationId: anim.id, property: "x", value: fromX }, - { label: "Move (GSAP from x)", skipReload: true }, - ); - await callbacks.commitMutation( - selection, - { type: "update-from-property", animationId: anim.id, property: "y", value: fromY }, - { label: "Move (GSAP from y)", skipReload: true }, - ); - } - - const toX = Math.round(Number(anim.properties.x ?? 0) + delta.x); - const toY = Math.round(Number(anim.properties.y ?? 0) + delta.y); - await callbacks.commitMutation( - selection, - { type: "update-property", animationId: anim.id, property: "x", value: toX }, - { label: "Move (GSAP to x)", skipReload: true }, - ); - await callbacks.commitMutation( - selection, - { type: "update-property", animationId: anim.id, property: "y", value: toY }, - { label: "Move (GSAP to y)", softReload: true, beforeReload }, - ); -} - // ── Runtime property reader ─────────────────────────────────────────────── export function readGsapProperty( diff --git a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts index 1029fd6df..a56043fbe 100644 --- a/packages/studio/src/hooks/gsapRuntimeKeyframes.ts +++ b/packages/studio/src/hooks/gsapRuntimeKeyframes.ts @@ -216,6 +216,12 @@ export function scanAllRuntimeKeyframes(iframe: HTMLIFrameElement | null): Map< for (const entry of result.values()) { entry.keyframes.sort((a, b) => a.percentage - b.percentage); + const seen = new Set(); + entry.keyframes = entry.keyframes.filter((kf) => { + if (seen.has(kf.percentage)) return false; + seen.add(kf.percentage); + return true; + }); } return result; } diff --git a/packages/studio/src/hooks/useAppHotkeys.ts b/packages/studio/src/hooks/useAppHotkeys.ts index 5140afc31..000b26df3 100644 --- a/packages/studio/src/hooks/useAppHotkeys.ts +++ b/packages/studio/src/hooks/useAppHotkeys.ts @@ -81,6 +81,7 @@ interface UseAppHotkeysParams { onResetKeyframes: () => boolean; onDeleteSelectedKeyframes: () => void; onAfterUndoRedo?: () => void; + onToggleRecording?: () => void; } // ── Hook ── @@ -106,6 +107,7 @@ export function useAppHotkeys({ onResetKeyframes, onDeleteSelectedKeyframes, onAfterUndoRedo, + onToggleRecording, }: UseAppHotkeysParams) { const previewHotkeyWindowRef = useRef(null); const handleAppKeyDownRef = useRef<((event: KeyboardEvent) => void) | undefined>(undefined); @@ -215,6 +217,8 @@ export function useAppHotkeys({ onResetKeyframesRef.current = onResetKeyframes; const onDeleteSelectedKeyframesRef = useRef(onDeleteSelectedKeyframes); onDeleteSelectedKeyframesRef.current = onDeleteSelectedKeyframes; + const onToggleRecordingRef = useRef(onToggleRecording); + onToggleRecordingRef.current = onToggleRecording; // ── Consolidated keydown handler ── @@ -377,6 +381,20 @@ export function useAppHotkeys({ void handleDomEditDeleteRef.current(domSelection); } } + + // R — toggle gesture recording + if ( + event.key === "r" && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + !isEditableTarget(event.target) && + onToggleRecordingRef.current + ) { + event.preventDefault(); + onToggleRecordingRef.current(); + } }; // ── Window keydown listener ── diff --git a/packages/studio/src/hooks/useDomEditCommits.ts b/packages/studio/src/hooks/useDomEditCommits.ts index c65e87b88..7f162a4c8 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( @@ -339,38 +345,39 @@ export function useDomEditCommits({ .join(":"); for (const { selection, next } of updates) { applyStudioPathOffset(selection.element, next); - if (isElementGsapTargeted(previewIframeRef.current, selection.element)) continue; + if (isElementGsapTargeted(previewIframeRef.current, selection.element)) { + onCommitAnimatedOffset?.(selection, next); + continue; + } commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), { label: `Move ${updates.length} layers`, coalesceKey: `group-path-offset:${coalesceKey}`, }); } }, - [commitPositionPatchToHtml, previewIframeRef], + [commitPositionPatchToHtml, previewIframeRef, onCommitAnimatedOffset], ); const handleDomBoxSizeCommit = useCallback( (selection: DomEditSelection, next: { width: number; height: number }) => { applyStudioBoxSize(selection.element, next); - if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return; commitPositionPatchToHtml(selection, buildBoxSizePatches(selection.element), { label: "Resize layer box", coalesceKey: `box-size:${getDomEditTargetKey(selection)}`, }); }, - [commitPositionPatchToHtml, previewIframeRef], + [commitPositionPatchToHtml], ); const handleDomRotationCommit = useCallback( (selection: DomEditSelection, next: { angle: number }) => { applyStudioRotation(selection.element, next); - if (isElementGsapTargeted(previewIframeRef.current, selection.element)) return; commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), { label: "Rotate layer", coalesceKey: `rotation:${getDomEditTargetKey(selection)}`, }); }, - [commitPositionPatchToHtml, previewIframeRef], + [commitPositionPatchToHtml], ); const handleDomManualEditsReset = useCallback( diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 02013491e..1cf7c23f1 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -272,6 +272,7 @@ export function useDomEditSession({ addGsapFromProperty, removeGsapFromProperty, addKeyframe, + addKeyframeBatch, removeKeyframe, convertToKeyframes, removeAllKeyframes, @@ -434,6 +435,7 @@ export function useDomEditSession({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, @@ -450,6 +452,7 @@ export function useDomEditSession({ addGsapFromProperty, removeGsapFromProperty, addKeyframe, + addKeyframeBatch, removeKeyframe, convertToKeyframes, removeAllKeyframes, @@ -623,6 +626,7 @@ export function useDomEditSession({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, @@ -632,5 +636,12 @@ export function useDomEditSession({ handleUpdateArcSegment, invalidateGsapCache: bumpGsapCache, previewIframeRef, + commitMutation: async ( + mutation: Record, + options: { label: string; softReload?: boolean }, + ) => { + if (!domEditSelection) return; + await gsapCommitMutation(domEditSelection, mutation, options); + }, }; } diff --git a/packages/studio/src/hooks/useEnableKeyframes.ts b/packages/studio/src/hooks/useEnableKeyframes.ts new file mode 100644 index 000000000..065cd74be --- /dev/null +++ b/packages/studio/src/hooks/useEnableKeyframes.ts @@ -0,0 +1,167 @@ +/** + * Centralized "Enable keyframes" logic that handles ALL scenarios: + * - Element has explicit keyframes → add/remove at seeked time + * - Element has a flat tween → convert + add at seeked time + propagate to end + * - Element has no animation (deleted) → create new tween with correct position + keyframes + * + * Always fetches fresh animation data to avoid stale session state. + * Reads GSAP runtime values only (no CSS offset — it applies separately via translate). + */ +import { useCallback } from "react"; +import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; +import type { DomEditSelection } from "../components/editor/domEditingTypes"; +import { usePlayerStore } from "../player/store/playerStore"; +import { fetchParsedAnimations, getAnimationsForElement } from "./useGsapTweenCache"; + +export interface EnableKeyframesSession { + domEditSelection: DomEditSelection | null; + selectedGsapAnimations: GsapAnimation[]; + previewIframeRef?: React.RefObject; + handleGsapAddAnimation: (method: "to" | "from" | "set" | "fromTo") => void; + handleGsapConvertToKeyframes: ( + animId: string, + resolvedFromValues?: Record, + ) => void | Promise; + handleGsapRemoveKeyframe: (animId: string, pct: number) => void; + handleGsapAddKeyframeBatch?: ( + animId: string, + pct: number, + properties: Record, + ) => Promise; + commitMutation?: ( + mutation: Record, + options: { label: string; softReload?: boolean }, + ) => Promise; +} + +function readElementPosition( + iframe: HTMLIFrameElement | null, + sel: DomEditSelection, + anim: GsapAnimation | null, +): Record { + const result: Record = {}; + if (!iframe?.contentWindow) return result; + + let gsap: { getProperty?: (el: Element, prop: string) => number } | undefined; + try { + gsap = (iframe.contentWindow as Window & { gsap?: typeof gsap }).gsap; + } catch { + return result; + } + + const element = sel.element; + if (!element?.isConnected || !gsap?.getProperty) return result; + + const props = anim ? Object.keys(anim.properties) : ["x", "y", "opacity"]; + for (const prop of props) { + const val = Number(gsap.getProperty(element, prop)); + if (Number.isFinite(val)) result[prop] = Math.round(val); + } + + return result; +} + +async function fetchAnimationsForElement(sel: DomEditSelection): Promise { + const projectId = window.location.hash.match(/project\/([^?/]+)/)?.[1]; + if (!projectId) return []; + const sourceFile = sel.sourceFile || "index.html"; + const parsed = await fetchParsedAnimations(projectId, sourceFile); + if (!parsed) return []; + return getAnimationsForElement(parsed.animations, { + id: sel.id, + selector: sel.selector, + }); +} + +function computePercentage(t: number, sel: DomEditSelection): number { + const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; + if (elDuration <= 0) return 0; + return Math.max(0, Math.min(100, Math.round(((t - elStart) / elDuration) * 1000) / 10)); +} + +// fallow-ignore-next-line complexity +export function useEnableKeyframes( + sessionRef: React.RefObject, +) { + return useCallback(async () => { + const session = sessionRef.current; + if (!session) return; + const sel = session.domEditSelection; + if (!sel) return; + + const t = usePlayerStore.getState().currentTime; + const iframe = session.previewIframeRef?.current ?? null; + + let anims = session.selectedGsapAnimations; + if (anims.length === 0) { + anims = await fetchAnimationsForElement(sel); + } + + const kfAnim = anims.find((a) => a.keyframes); + const flatAnim = anims.find((a) => !a.keyframes); + + if (kfAnim?.keyframes) { + const pct = computePercentage(t, sel); + const existing = kfAnim.keyframes.keyframes.find((k) => Math.abs(k.percentage - pct) <= 1); + if (existing) { + session.handleGsapRemoveKeyframe(kfAnim.id, existing.percentage); + } else if (session.handleGsapAddKeyframeBatch) { + const position = readElementPosition(iframe, sel, kfAnim); + if (Object.keys(position).length > 0) { + await session.handleGsapAddKeyframeBatch(kfAnim.id, pct, position); + } + } + } else if (flatAnim) { + const position = readElementPosition(iframe, sel, flatAnim); + const hasPosition = Object.keys(position).length > 0; + + await session.handleGsapConvertToKeyframes(flatAnim.id, hasPosition ? position : undefined); + + const pct = computePercentage(t, sel); + if (pct > 1 && pct < 99 && hasPosition && session.handleGsapAddKeyframeBatch) { + await session.handleGsapAddKeyframeBatch(flatAnim.id, pct, position); + await session.handleGsapAddKeyframeBatch(flatAnim.id, 100, position); + } + } else { + const position = readElementPosition(iframe, sel, null); + const pct = computePercentage(t, sel); + const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; + const elDuration = Number.parseFloat(sel.dataAttributes?.duration ?? "1") || 1; + const selector = sel.id ? `#${sel.id}` : sel.selector; + + if (!selector) { + session.handleGsapAddAnimation("to"); + return; + } + + if (Object.keys(position).length === 0) { + position.x = 0; + position.y = 0; + position.opacity = 1; + } + + const keyframes: Array<{ percentage: number; properties: Record }> = + [{ percentage: 0, properties: { ...position } }]; + if (pct > 1 && pct < 99) { + keyframes.push({ percentage: pct, properties: { ...position } }); + } + keyframes.push({ percentage: 100, properties: { ...position } }); + + if (session.commitMutation) { + await session.commitMutation( + { + type: "add-with-keyframes", + targetSelector: selector, + position: Math.round(elStart * 1000) / 1000, + duration: Math.round(elDuration * 1000) / 1000, + keyframes, + }, + { label: "Enable keyframes", softReload: true }, + ); + } else { + session.handleGsapAddAnimation("to"); + } + } + }, [sessionRef]); +} diff --git a/packages/studio/src/hooks/useGestureCommit.ts b/packages/studio/src/hooks/useGestureCommit.ts new file mode 100644 index 000000000..86501341c --- /dev/null +++ b/packages/studio/src/hooks/useGestureCommit.ts @@ -0,0 +1,56 @@ +import { useCallback } from "react"; +import type { DomEditSelection } from "../components/editor/domEditing"; + +interface GestureCommitParams { + commitMutation: ( + selection: DomEditSelection, + mutation: Record, + options: { label: string; softReload: boolean }, + ) => Promise; +} + +export function useGestureCommit({ commitMutation }: GestureCommitParams) { + const commitGestureKeyframes = useCallback( + async ( + selection: DomEditSelection, + animationId: string, + keyframes: Map>, + eases: Map, + ) => { + const sortedPcts = Array.from(keyframes.keys()).sort((a, b) => a - b); + if (sortedPcts.length < 2) return; + + await commitMutation( + selection, + { type: "convert-to-keyframes" as const, animationId }, + { label: "Convert to keyframes for gesture recording", softReload: false }, + ); + + for (const pct of sortedPcts) { + const props = keyframes.get(pct); + if (!props) continue; + + for (const [prop, value] of Object.entries(props)) { + await commitMutation( + selection, + { + type: "add-keyframe" as const, + animationId, + percentage: pct, + property: prop, + value, + ease: eases.get(pct), + }, + { + label: `Record keyframe at ${pct}%`, + softReload: pct === sortedPcts[sortedPcts.length - 1], + }, + ); + } + } + }, + [commitMutation], + ); + + return { commitGestureKeyframes }; +} diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts new file mode 100644 index 000000000..93e47361f --- /dev/null +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -0,0 +1,305 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { usePlayerStore, liveTime } from "../player/store/playerStore"; + +export interface GestureSample { + time: number; + properties: Record; +} + +interface Modifiers { + shift: boolean; + alt: boolean; + meta: boolean; +} + +interface AccumulatedState { + opacity: number; + scale: number; + z: number; +} + +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) { + 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) { + properties.rotationX = dy * 0.5; + properties.rotationY = dx * 0.5; + } else if (modifiers.alt) { + properties.rotation = dx * 0.5; + } else { + properties.x = dx; + properties.y = dy; + } + + if (!modifiers.meta && scrollDelta !== 0) { + nextZ = accumulatedState.z + scrollDelta; + properties.z = nextZ; + } + + return { + properties, + nextState: { opacity: nextOpacity, scale: nextScale, z: nextZ }, + }; +} + +export function useGestureRecording() { + const [isRecording, setIsRecording] = useState(false); + const [recordingDuration, setRecordingDuration] = useState(0); + + // Synchronous guard — immune to React's async state batching. + // startRecording and stopRecording check this ref, not the useState value. + const isRecordingRef = useRef(false); + + const pointerRef = useRef({ x: 0, y: 0 }); + const startPointerRef = useRef({ x: 0, y: 0 }); + const scrollDeltaRef = useRef(0); + const modifiersRef = useRef({ shift: false, alt: false, meta: false }); + const accumulatedRef = useRef({ opacity: 1, scale: 1, z: 0 }); + const basePositionRef = useRef({ x: 0, y: 0 }); + const scaleRef = useRef(1); + const hasMovedRef = useRef(false); + const runtimeRef = useRef<{ + seek: (t: number) => void; + set: (target: string, vars: Record) => void; + selector: string; + startTime: number; + duration: number; + } | null>(null); + + const rafIdRef = useRef(0); + const samplesRef = useRef([]); + const trailRef = useRef>([]); + const cleanupRef = useRef<(() => void) | null>(null); + + // Unmount safety: cancel RAF + remove listeners if component tears down mid-recording. + useEffect(() => { + return () => { + cleanupRef.current?.(); + cleanupRef.current = null; + isRecordingRef.current = false; + }; + }, []); + + const startRecording = useCallback( + (element: HTMLElement, iframeEl: HTMLIFrameElement) => { + if (isRecordingRef.current) return; + isRecordingRef.current = true; + + samplesRef.current = []; + trailRef.current = []; + hasMovedRef.current = false; + setRecordingDuration(0); + scrollDeltaRef.current = 0; + + let baseOpacity = 1; + let baseScaleVal = 1; + let baseX = 0; + let baseY = 0; + try { + const gsap = ( + iframeEl.contentWindow as Window & { + gsap?: { getProperty: (el: Element, prop: string) => number }; + } + ).gsap; + if (gsap?.getProperty) { + baseOpacity = Number(gsap.getProperty(element, "opacity")) || 1; + baseScaleVal = Number(gsap.getProperty(element, "scaleX")) || 1; + baseX = Number(gsap.getProperty(element, "x")) || 0; + baseY = Number(gsap.getProperty(element, "y")) || 0; + } + } catch { + /* cross-origin guard */ + } + accumulatedRef.current = { opacity: baseOpacity, scale: baseScaleVal, z: 0 }; + basePositionRef.current = { x: baseX, y: baseY }; + + const selector = element.id ? `#${element.id}` : null; + try { + const win = iframeEl.contentWindow as Window & { + gsap?: { set: (t: string, v: Record) => void }; + __timelines?: Record void; duration: () => number }>; + __player?: { getTime: () => number }; + }; + const tl = win?.__timelines ? Object.values(win.__timelines)[0] : null; + if (win?.gsap?.set && tl?.seek && selector) { + runtimeRef.current = { + seek: tl.seek.bind(tl), + set: win.gsap.set.bind(win.gsap), + selector, + startTime: win.__player?.getTime() ?? 0, + duration: tl.duration(), + }; + } + } catch { + runtimeRef.current = null; + } + + const iframeRect = iframeEl.getBoundingClientRect(); + const doc = iframeEl.contentDocument; + const root = doc?.querySelector("[data-composition-id]") ?? doc?.documentElement; + const declaredWidth = Number(root?.getAttribute("data-width")) || 1920; + scaleRef.current = declaredWidth > 0 ? iframeRect.width / declaredWidth : 1; + + 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 handleKeyChange = (e: KeyboardEvent) => { + modifiersRef.current = { + shift: e.shiftKey, + alt: e.altKey, + meta: e.metaKey || e.ctrlKey, + }; + }; + + document.addEventListener("pointermove", handlePointerMove, { passive: true }); + document.addEventListener("wheel", handleWheel, { passive: true }); + document.addEventListener("keydown", handleKeyChange, { passive: true }); + document.addEventListener("keyup", handleKeyChange, { passive: true }); + + startPointerRef.current = { ...pointerRef.current }; + const startMs = performance.now(); + + let startCaptured = false; + const captureStart = (e: PointerEvent) => { + if (!startCaptured) { + startPointerRef.current = { x: e.clientX, y: e.clientY }; + startCaptured = true; + hasMovedRef.current = true; + } + }; + document.addEventListener("pointermove", captureStart, { passive: true, once: true }); + + const tick = () => { + if (!isRecordingRef.current) return; + const now = performance.now(); + const time = (now - startMs) / 1000; + const scale = scaleRef.current || 1; + const dx = (pointerRef.current.x - startPointerRef.current.x) / scale; + const dy = (pointerRef.current.y - startPointerRef.current.y) / scale; + const scrollDelta = scrollDeltaRef.current; + + // Skip zero-displacement samples before the pointer has moved. + if (!hasMovedRef.current && dx === 0 && dy === 0 && scrollDelta === 0) { + rafIdRef.current = requestAnimationFrame(tick); + return; + } + hasMovedRef.current = true; + + const { properties, nextState } = resolveGestureProperties( + dx, + dy, + scrollDelta, + modifiersRef.current, + accumulatedRef.current, + ); + if ("x" in properties) properties.x = Math.round(basePositionRef.current.x + properties.x); + if ("y" in properties) properties.y = Math.round(basePositionRef.current.y + properties.y); + + accumulatedRef.current = nextState; + scrollDeltaRef.current = 0; + + // Manual seek on the raw GSAP timeline (not the Studio player wrapper, + // which triggers React state updates). After seek renders all elements + // at the correct time, gsap.set overrides the recorded element so it + // follows the pointer. The browser paints the set values on this frame; + // next tick's seek will overwrite, but we re-apply immediately. + if (runtimeRef.current) { + try { + const seekTime = Math.min( + runtimeRef.current.startTime + time, + runtimeRef.current.duration, + ); + runtimeRef.current.seek(seekTime); + runtimeRef.current.set(runtimeRef.current.selector, { ...properties }); + liveTime.notify(seekTime); + usePlayerStore.getState().setCurrentTime(seekTime); + } catch { + runtimeRef.current = null; + } + } + + samplesRef.current.push({ time, properties }); + // Screen-space pointer position for the ghost trail overlay + trailRef.current.push({ x: pointerRef.current.x, y: pointerRef.current.y }); + setRecordingDuration(time); + rafIdRef.current = requestAnimationFrame(tick); + }; + + setIsRecording(true); + rafIdRef.current = requestAnimationFrame(tick); + + cleanupRef.current = () => { + cancelAnimationFrame(rafIdRef.current); + document.removeEventListener("pointermove", handlePointerMove); + document.removeEventListener("wheel", handleWheel); + document.removeEventListener("keydown", handleKeyChange); + document.removeEventListener("keyup", handleKeyChange); + document.removeEventListener("pointermove", captureStart); + }; + }, + [], // No deps — uses refs only for all mutable state + ); + + const stopRecording = useCallback((): GestureSample[] => { + if (!isRecordingRef.current) return []; + isRecordingRef.current = false; + runtimeRef.current = null; + cleanupRef.current?.(); + cleanupRef.current = null; + const frozen = samplesRef.current.slice(); + setRecordingDuration(frozen.length > 0 ? frozen[frozen.length - 1]!.time : 0); + setIsRecording(false); + return frozen; + }, []); // No deps — uses refs only + + const clearSamples = useCallback(() => { + samplesRef.current = []; + setRecordingDuration(0); + accumulatedRef.current = { opacity: 1, scale: 1, z: 0 }; + scrollDeltaRef.current = 0; + }, []); + + return { + startRecording, + stopRecording, + isRecording, + samplesRef, + trailRef, + recordingDuration, + clearSamples, + }; +} diff --git a/packages/studio/src/hooks/useGsapScriptCommits.ts b/packages/studio/src/hooks/useGsapScriptCommits.ts index 57d62c36e..ad4459475 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) { @@ -467,6 +467,21 @@ export function useGsapScriptCommits({ }, [commitMutation, activeCompPath], ); + const addKeyframeBatch = useCallback( + ( + selection: DomEditSelection, + animationId: string, + percentage: number, + properties: Record, + ) => { + return commitMutation( + selection, + { type: "add-keyframe", animationId, percentage, properties }, + { label: `Add keyframe at ${percentage}%`, softReload: true }, + ); + }, + [commitMutation], + ); const removeKeyframe = useCallback( (selection: DomEditSelection, animationId: string, percentage: number) => { const sf = selection.sourceFile || activeCompPath || "index.html"; @@ -499,7 +514,7 @@ export function useGsapScriptCommits({ animationId: string, resolvedFromValues?: Record, ) => { - void commitMutation( + return commitMutation( selection, { type: "convert-to-keyframes", animationId, resolvedFromValues }, { label: "Convert to keyframes" }, @@ -589,6 +604,7 @@ export function useGsapScriptCommits({ addGsapFromProperty, removeGsapFromProperty, addKeyframe, + addKeyframeBatch, removeKeyframe, convertToKeyframes, removeAllKeyframes, diff --git a/packages/studio/src/hooks/useGsapSelectionHandlers.ts b/packages/studio/src/hooks/useGsapSelectionHandlers.ts index 60f288d8e..50e44deb7 100644 --- a/packages/studio/src/hooks/useGsapSelectionHandlers.ts +++ b/packages/studio/src/hooks/useGsapSelectionHandlers.ts @@ -19,6 +19,7 @@ export function useGsapSelectionHandlers({ addGsapFromProperty, removeGsapFromProperty, addKeyframe, + addKeyframeBatch, removeKeyframe, convertToKeyframes, removeAllKeyframes, @@ -61,6 +62,12 @@ export function useGsapSelectionHandlers({ property: string, value: number | string, ) => void; + addKeyframeBatch: ( + sel: DomEditSelection, + animId: string, + percentage: number, + properties: Record, + ) => Promise; removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void; convertToKeyframes: ( sel: DomEditSelection, @@ -155,6 +162,13 @@ export function useGsapSelectionHandlers({ [domEditSelection, addKeyframe], ); + const handleGsapAddKeyframeBatch = useCallback( + (animId: string, percentage: number, properties: Record) => { + if (!domEditSelection) return Promise.resolve(); + return addKeyframeBatch(domEditSelection, animId, percentage, properties); + }, + [domEditSelection, addKeyframeBatch], + ); const handleGsapRemoveKeyframe = useCallback( (animId: string, percentage: number) => { if (!domEditSelection) return; @@ -165,8 +179,8 @@ export function useGsapSelectionHandlers({ const handleGsapConvertToKeyframes = useCallback( (animId: string, resolvedFromValues?: Record) => { - if (!domEditSelection) return; - convertToKeyframes(domEditSelection, animId, resolvedFromValues); + if (!domEditSelection) return Promise.resolve(); + return convertToKeyframes(domEditSelection, animId, resolvedFromValues); }, [domEditSelection, convertToKeyframes], ); @@ -198,6 +212,7 @@ export function useGsapSelectionHandlers({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 4075c711c..d398345a5 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -95,7 +95,12 @@ export function getAnimationsForElement( if (target.selector) matchers.add(target.selector); if (matchers.size === 0) return []; return animations.filter((a) => - a.targetSelector.split(",").some((part) => matchers.has(part.trim())), + a.targetSelector.split(",").some((part) => { + const trimmed = part.trim(); + if (matchers.has(trimmed)) return true; + const lastSimple = trimmed.split(/\s+/).pop(); + return lastSimple ? matchers.has(lastSimple) : false; + }), ); } diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts index 985025bbd..6204f21bc 100644 --- a/packages/studio/src/hooks/useStudioContextValue.ts +++ b/packages/studio/src/hooks/useStudioContextValue.ts @@ -81,6 +81,7 @@ export function useInspectorState( rightCollapsed: boolean, isPlaying: boolean, domEditSelection: DomEditSelection | null, + isGestureRecording?: boolean, ): InspectorState { // fallow-ignore-next-line complexity return useMemo(() => { @@ -101,9 +102,10 @@ export function useInspectorState( inspectorPanelActive, inspectorButtonActive: STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive, - shouldShowSelectedDomBounds: inspectorPanelActive && !rightCollapsed && !isPlaying, + shouldShowSelectedDomBounds: + inspectorPanelActive && !rightCollapsed && (!isPlaying || !!isGestureRecording), }; - }, [rightPanelTab, rightCollapsed, isPlaying, domEditSelection]); + }, [rightPanelTab, rightCollapsed, isPlaying, domEditSelection, isGestureRecording]); } // fallow-ignore-next-line complexity diff --git a/packages/studio/src/hooks/useToast.ts b/packages/studio/src/hooks/useToast.ts index 79ca444ab..a27147a9b 100644 --- a/packages/studio/src/hooks/useToast.ts +++ b/packages/studio/src/hooks/useToast.ts @@ -16,5 +16,10 @@ export function useToast() { if (timerRef.current) clearTimeout(timerRef.current); }); - return { appToast, showToast }; + const dismissToast = useCallback(() => { + if (timerRef.current) clearTimeout(timerRef.current); + setAppToast(null); + }, []); + + return { appToast, showToast, dismissToast }; } diff --git a/packages/studio/src/player/components/ShortcutsPanel.tsx b/packages/studio/src/player/components/ShortcutsPanel.tsx index c5c201b3e..b91ef6398 100644 --- a/packages/studio/src/player/components/ShortcutsPanel.tsx +++ b/packages/studio/src/player/components/ShortcutsPanel.tsx @@ -27,6 +27,36 @@ const SHORTCUT_SECTIONS = [ { key: "R", label: "Record gesture" }, ], }, + { + title: "Editing", + hints: [ + { key: "⌘Z", label: "Undo" }, + { key: "⌘⇧Z", label: "Redo" }, + { key: "⌘C", label: "Copy element" }, + { key: "⌘V", label: "Paste element" }, + { key: "⌘X", label: "Cut element" }, + { key: "S", label: "Split clip at playhead" }, + { key: "Del", label: "Delete selected element" }, + ], + }, + { + title: "Gesture recording modifiers", + hints: [ + { key: "Drag", label: "Record x / y position" }, + { key: "Scroll", label: "Record z depth" }, + { key: "⇧ Drag", label: "Record rotationX / rotationY" }, + { key: "⌥ Drag", label: "Record rotation" }, + { key: "⌘ Drag↕", label: "Record opacity" }, + { key: "⌘ Scroll", label: "Record scale" }, + ], + }, + { + title: "Panels", + hints: [ + { key: "⌘1", label: "Compositions tab" }, + { key: "⌘2", label: "Assets tab" }, + ], + }, { title: "Work area", hints: [ diff --git a/packages/studio/src/player/components/TimelineClipDiamonds.tsx b/packages/studio/src/player/components/TimelineClipDiamonds.tsx index 502f19aff..acad691e1 100644 --- a/packages/studio/src/player/components/TimelineClipDiamonds.tsx +++ b/packages/studio/src/player/components/TimelineClipDiamonds.tsx @@ -102,7 +102,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ const x2 = (kf.percentage / 100) * clipWidthPx; return (
{ + {sorted.map((kf, i) => { const leftPx = (kf.percentage / 100) * clipWidthPx - half; const kfKey = `${elementId}:${kf.percentage}`; const isKfSelected = selectedKeyframes.has(kfKey); @@ -126,7 +126,7 @@ export const TimelineClipDiamonds = memo(function TimelineClipDiamonds({ const color = isKfSelected || atPlayhead ? accentColor : "#a3a3a3"; return (