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..bdd4c606f 100644 --- a/packages/core/src/parsers/gsapParser.ts +++ b/packages/core/src/parsers/gsapParser.ts @@ -1167,6 +1167,7 @@ export function addAnimationWithKeyframesToScript( percentage: number; properties: Record; ease?: string; + auto?: boolean; }>, ease?: string, ): { script: string; id: string } { @@ -1187,6 +1188,7 @@ export function addAnimationWithKeyframesToScript( ([k, v]) => `${safeKey(k)}: ${valueToCode(v)}`, ); if (kf.ease) propEntries.push(`ease: ${JSON.stringify(kf.ease)}`); + if (kf.auto) propEntries.push(`_auto: 1`); return `${JSON.stringify(`${kf.percentage}%`)}: { ${propEntries.join(", ")} }`; }); const kfCode = `{ ${kfEntries.join(", ")} }`; @@ -1354,10 +1356,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); @@ -1387,6 +1404,24 @@ export function addKeyframeToScript( kfNode.properties.splice(insertIdx, 0, newProp); } + // Auto-update 100%: if the 100% keyframe still has `_auto: 1` (never + // explicitly edited by the user), update it to match the new keyframe's + // values so the element holds its final position instead of snapping back. + // Once the user drags at 100%, `_auto` is gone and we stop touching it. + if (percentage < 100 && percentage !== 0) { + const pctProps = filterPercentageProps(kfNode); + const hundredProp = pctProps.find((p: any) => percentageFromKey(propKeyName(p) ?? "") === 100); + if (hundredProp?.value?.type === "ObjectExpression") { + const hasAuto = hundredProp.value.properties.some( + (p: any) => isObjectProperty(p) && propKeyName(p) === "_auto", + ); + if (hasAuto) { + const updatedProps = { ...properties, _auto: 1 as number | string }; + hundredProp.value = buildKeyframeValueNode(updatedProps, undefined); + } + } + } + // Backfill: when the new keyframe introduces properties absent from other // keyframes, add default values so GSAP can interpolate them. if (backfillDefaults) { diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index a4758d62f..73cd6fff1 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -954,33 +954,13 @@ export function initSandboxRuntimeModular(): void { state.capturedTimeline.totalTime(seekTime, false); } - // Strip stale CSS offset artifacts from GSAP-targeted elements. - // These leak into the HTML when the CSS offset path fires for a - // GSAP-animated element (stale cache race). On reload, both the - // offset and GSAP transform stack, doubling the visual position. - const staleEls = document.querySelectorAll("[data-hf-studio-path-offset]"); - if (staleEls.length > 0 && state.capturedTimeline.getChildren) { - const tweenTargets = new Set(); - try { - for (const child of state.capturedTimeline.getChildren(true)) { - if (typeof child.targets === "function") { - for (const t of child.targets()) tweenTargets.add(t); - } - } - } catch { - /* timeline access guard */ - } - for (const el of staleEls) { - if (!tweenTargets.has(el)) continue; - const htmlEl = el as HTMLElement; - htmlEl.removeAttribute("data-hf-studio-path-offset"); - htmlEl.removeAttribute("data-hf-studio-original-translate"); - htmlEl.removeAttribute("data-hf-studio-original-inline-translate"); - htmlEl.style.removeProperty("--hf-studio-offset-x"); - htmlEl.style.removeProperty("--hf-studio-offset-y"); - htmlEl.style.removeProperty("translate"); - } - } + // GSAP bakes the CSS `translate` into style.transform on seek. + // The Studio seek wrapper (installStudioManualEditSeekReapply) calls + // reapplyPositionEditsAfterSeek to un-bake it. Call the apply hook + // directly here as well, since the wrapper may not be installed yet + // during initial rebind (timing race on first load / soft reload). + const applyFn = (window as Record).__hfStudioManualEditsApply; + if (typeof applyFn === "function") applyFn(); } if (resolution.diagnostics) { postRuntimeMessage({ @@ -1002,23 +982,21 @@ 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. - { + // Only when embedded in an iframe (Studio preview) — production renders + // run as the top-level page and must not mutate element timing. + if (window.parent !== window) { 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; + // Elements inside a sub-composition host are managed by the host's + // clip window — stamping them with root-level timing makes the + // visibility system treat them as always-visible, overriding the + // parent's hidden state after the clip ends. + const isInsideSubComposition = (el: Element): boolean => { + const host = el.closest("[data-composition-src],[data-composition-file]"); + return host !== null && host !== rootComp; }; // Stamp GSAP-targeted elements @@ -1030,8 +1008,8 @@ 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; + if (isInsideSubComposition(target)) continue; seen.add(target); target.setAttribute("data-start", "0"); target.setAttribute("data-duration", dur); @@ -1050,9 +1028,9 @@ 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; + if (isInsideSubComposition(el)) continue; seen.add(el); el.setAttribute("data-start", "0"); el.setAttribute("data-duration", dur); 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/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts index 96f02bb58..f76f2e9a5 100644 --- a/packages/core/src/studio-api/routes/files.ts +++ b/packages/core/src/studio-api/routes/files.ts @@ -681,6 +681,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { percentage: number; properties: Record; ease?: string; + auto?: boolean; }>; ease?: string; }; diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index af9edf131..bcbaf68d8 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 { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay"; import { StudioLeftSidebar } from "./components/StudioLeftSidebar"; import { StudioPreviewArea } from "./components/StudioPreviewArea"; import { StudioRightPanel } from "./components/StudioRightPanel"; @@ -94,7 +98,6 @@ export function StudioApp() { const captionEditMode = useCaptionStore((s) => s.isEditMode); const captionHasSelection = useCaptionStore((s) => s.selectedSegmentIds.size > 0); const captionSync = useCaptionSync(projectId); - const currentTime = usePlayerStore((s) => s.currentTime); const timelineElements = usePlayerStore((s) => s.elements); const setSelectedTimelineElementId = usePlayerStore((s) => s.setSelectedElementId); const timelineDuration = usePlayerStore((s) => s.duration); @@ -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, @@ -136,6 +139,7 @@ export function StudioApp() { const editHistory = usePersistentEditHistory({ projectId }); const domEditSaveTimestampRef = useRef(0); const pendingTimelineEditPathRef = useRef(new Set()); + const isGestureRecordingRef = useRef(false); const reloadPreview = useCallback(() => { setRefreshKey((k) => k + 1); }, []); @@ -185,6 +189,7 @@ export function StudioApp() { previewIframeRef, pendingTimelineEditPathRef, uploadProjectFiles: fileManager.uploadProjectFiles, + isRecordingRef: isGestureRecordingRef, }); const blockCtx = useMemo( @@ -306,6 +311,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), @@ -325,7 +331,6 @@ export function StudioApp() { compositionLoading, previewIframeRef, timelineElements, - currentTime, setSelectedTimelineElementId, setRightCollapsed: panelLayout.setRightCollapsed, setRightPanelTab: panelLayout.setRightPanelTab, @@ -400,6 +405,128 @@ export function StudioApp() { const dragOverlay = useDragOverlay(fileManager.handleImportFiles); + // Gesture recording + const gestureRecording = useGestureRecording(); + const [gestureState, setGestureState] = useState<"idle" | "recording">("idle"); + // Synchronous mirror of gestureState — immune to React batching. + // Prevents double-R-press within a single render cycle from swallowing the stop. + const gestureStateRef = useRef<"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 () => { + clearInterval(recordingAutoStopRef.current); + if (commitInFlightRef.current) return; + commitInFlightRef.current = true; + gestureStateRef.current = "idle"; + isGestureRecordingRef.current = false; + const frozenSamples = gestureRecording.stopRecording(); + const store = usePlayerStore.getState(); + store.setIsPlaying(false); + try { + const liveSession = domEditSessionRef.current; + const sel = liveSession.domEditSelection; + if (!sel) { + if (frozenSamples.length > 2) { + showToast("Selection lost during recording", "error"); + } + return; + } + const duration = frozenSamples.length > 0 ? frozenSamples[frozenSamples.length - 1]!.time : 0; + + if (frozenSamples.length <= 2) { + showToast("No gesture detected — move the pointer while recording", "error"); + return; + } + if (duration <= 0) { + showToast("Recording too short — try again", "error"); + return; + } + + const simplified = simplifyGestureSamples(frozenSamples, duration, 5); + const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b); + + // Always create a new tween scoped to the recording range. + // Injecting into an existing tween creates keyframes before the recording + // start (from the convert-to-keyframes step), causing wrong positions. + const selector = sel.id ? `#${sel.id}` : sel.selector; + if (!selector) { + showToast("Cannot save — element has no selector", "error"); + return; + } + if (liveSession.commitMutation) { + const recStart = recordingStartTimeRef.current; + 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(recStart * 1000) / 1000, + duration: Math.round(duration * 1000) / 1000, + keyframes, + }, + { label: "Gesture recording", softReload: true }, + ); + } + showToast(`Recorded ${sortedPcts.length} keyframes`, "info"); + } finally { + store.requestSeek(recordingStartTimeRef.current); + gestureRecording.clearSamples(); + setGestureState("idle"); + commitInFlightRef.current = false; + } + }, [gestureRecording, showToast]); + + const handleToggleRecording = useCallback(() => { + if (gestureStateRef.current === "recording") { + void stopAndCommitRecording(); + return; + } + const sel = domEditSessionRef.current.domEditSelection; + if (!sel) { + showToast("Select an element first", "error"); + return; + } + const iframe = previewIframeRef.current; + if (!iframe) { + showToast("Preview not ready — try again", "error"); + return; + } + + const store = usePlayerStore.getState(); + recordingStartTimeRef.current = store.currentTime; + const elStart = Number.parseFloat(sel.dataAttributes?.start ?? "0") || 0; + const elDur = Number.parseFloat(sel.dataAttributes?.duration ?? "0") || 0; + const elementEnd = elDur > 0 ? elStart + elDur : undefined; + gestureRecording.startRecording(sel.element, iframe, elementEnd); + gestureStateRef.current = "recording"; + isGestureRecordingRef.current = true; + setGestureState("recording"); + + clearInterval(recordingAutoStopRef.current); + const autoStopAt = elementEnd ?? Infinity; + recordingAutoStopRef.current = setInterval(() => { + const { currentTime: t, duration: d } = usePlayerStore.getState(); + const limit = Math.min(autoStopAt, d); + if (limit > 0 && t >= limit - 0.05) { + void stopAndCommitRecording(); + } + }, 100); + }, [gestureRecording, showToast, stopAndCommitRecording]); + handleToggleRecordingRef.current = handleToggleRecording; + const handlePreviewIframeRef = useCallback( (iframe: HTMLIFrameElement | null) => { previewIframeRef.current = iframe; @@ -435,12 +562,12 @@ export function StudioApp() { panelLayout.rightCollapsed, isPlaying, domEditSession.domEditSelection, + gestureState === "recording", ); useStudioUrlState({ projectId, activeCompPath, - currentTime, duration: effectiveTimelineDuration, isPlaying, compositionLoading, @@ -466,7 +593,6 @@ export function StudioApp() { compositionLoading, refreshKey, setRefreshKey, - currentTime, timelineElements, isPlaying, editHistory, @@ -541,7 +667,23 @@ export function StudioApp() { setCompIdToSrc={setCompIdToSrc} setCompositionLoading={setCompositionLoading} shouldShowSelectedDomBounds={shouldShowSelectedDomBounds} + isGestureRecording={gestureState === "recording"} blockPreview={blockPreview} + gestureOverlay={ + 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" + /> + ) : undefined + } /> {!panelLayout.rightCollapsed && ( @@ -554,6 +696,9 @@ export function StudioApp() { setActiveBlockParams(null); panelLayout.setRightPanelTab("design"); }} + recordingState={gestureState} + recordingDuration={gestureRecording.recordingDuration} + onToggleRecording={handleToggleRecording} /> )}
@@ -586,7 +731,13 @@ export function StudioApp() { )} {dragOverlay.active && } - {appToast && } + {appToast && ( + + )} diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index 5399d3f42..4ac1b9230 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -56,6 +56,8 @@ export interface StudioPreviewAreaProps { setCompositionLoading: (loading: boolean) => void; shouldShowSelectedDomBounds: boolean; blockPreview?: BlockPreviewInfo | null; + isGestureRecording?: boolean; + gestureOverlay?: ReactNode; } // fallow-ignore-next-line complexity @@ -74,7 +76,9 @@ export function StudioPreviewArea({ setCompIdToSrc, setCompositionLoading, shouldShowSelectedDomBounds, + isGestureRecording, blockPreview, + gestureOverlay, }: StudioPreviewAreaProps) { const { projectId, @@ -241,7 +245,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} @@ -256,6 +260,7 @@ export function StudioPreviewArea({ gridSpacing={snapPrefs.gridSpacing} /> + {gestureOverlay} ) : null } 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/DomEditOverlay.tsx b/packages/studio/src/components/editor/DomEditOverlay.tsx index a8625355b..66129639b 100644 --- a/packages/studio/src/components/editor/DomEditOverlay.tsx +++ b/packages/studio/src/components/editor/DomEditOverlay.tsx @@ -252,6 +252,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ groupOverlayItems.every((item) => item.selection.capabilities.canApplyManualOffset); const handleOverlayMouseDown = (event: React.MouseEvent) => { + if (!allowCanvasMovement) return; if (suppressNextOverlayMouseDownRef.current) { suppressNextOverlayMouseDownRef.current = false; suppressNextBoxMouseDownRef.current = false; @@ -312,6 +313,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ }; const handleBoxClick = (event: React.MouseEvent) => { + if (!allowCanvasMovement) return; if (gestureRef.current || groupGestureRef.current) return; if (suppressNextBoxClickRef.current) { suppressNextBoxClickRef.current = false; @@ -344,7 +346,7 @@ export const DomEditOverlay = memo(function DomEditOverlay({ onPointerUp={gestures.onPointerUp} onPointerCancel={() => gestures.clearPointerState(selectionRef)} > - {hoverSelection && hoverRect && ( + {hoverSelection && hoverRect && compRect.width > 0 && ( ); diff --git a/packages/studio/src/contexts/DomEditContext.tsx b/packages/studio/src/contexts/DomEditContext.tsx index 985cbc57b..c62fda6b4 100644 --- a/packages/studio/src/contexts/DomEditContext.tsx +++ b/packages/studio/src/contexts/DomEditContext.tsx @@ -67,6 +67,7 @@ export function DomEditProvider({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, @@ -76,6 +77,7 @@ export function DomEditProvider({ handleUpdateArcSegment, invalidateGsapCache, previewIframeRef, + commitMutation, }, children, }: { @@ -138,6 +140,7 @@ export function DomEditProvider({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, @@ -147,6 +150,7 @@ export function DomEditProvider({ handleUpdateArcSegment, invalidateGsapCache, previewIframeRef, + commitMutation, }), [ domEditSelection, @@ -203,6 +207,7 @@ export function DomEditProvider({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, @@ -212,6 +217,7 @@ export function DomEditProvider({ handleUpdateArcSegment, invalidateGsapCache, previewIframeRef, + commitMutation, ], ); return {children}; diff --git a/packages/studio/src/contexts/StudioContext.tsx b/packages/studio/src/contexts/StudioContext.tsx index 1a61a33f7..48787d076 100644 --- a/packages/studio/src/contexts/StudioContext.tsx +++ b/packages/studio/src/contexts/StudioContext.tsx @@ -12,7 +12,6 @@ export interface StudioContextValue { compositionLoading: boolean; refreshKey: number; setRefreshKey: React.Dispatch>; - currentTime: number; timelineElements: TimelineElement[]; isPlaying: boolean; editHistory: { @@ -63,7 +62,6 @@ export function StudioProvider({ compositionLoading, refreshKey, setRefreshKey, - currentTime, timelineElements, isPlaying, editHistory, @@ -89,7 +87,6 @@ export function StudioProvider({ compositionLoading, refreshKey, setRefreshKey, - currentTime, timelineElements, isPlaying, editHistory, @@ -112,7 +109,6 @@ export function StudioProvider({ captionEditMode, compositionLoading, refreshKey, - currentTime, isPlaying, compositionDimensions, timelineVisible, diff --git a/packages/studio/src/hooks/gsapRuntimeBridge.ts b/packages/studio/src/hooks/gsapRuntimeBridge.ts index 6e641dfc8..3ac4036c9 100644 --- a/packages/studio/src/hooks/gsapRuntimeBridge.ts +++ b/packages/studio/src/hooks/gsapRuntimeBridge.ts @@ -10,7 +10,7 @@ */ import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import type { DomEditSelection } from "../components/editor/domEditingTypes"; -import { clearStudioPathOffset } from "../components/editor/manualEdits"; + import { usePlayerStore } from "../player/store/playerStore"; import { readRuntimeKeyframes, scanAllRuntimeKeyframes } from "./gsapRuntimeKeyframes"; import { @@ -202,6 +202,10 @@ export async function tryGsapDragIntercept( const selector = selectorForSelection(selection); if (!selector) return false; + // Keyframe writes at 0%/100% when outside the tween range. Acceptable + // trade-off — CSS path must NEVER touch GSAP-targeted elements because + // changing the CSS offset corrupts all existing keyframes (baked mismatch). + const gsapPos = readGsapPositionFromIframe(iframe, selector); if (!gsapPos) return false; @@ -244,48 +248,153 @@ async function commitGsapPositionFromDrag( const rad = (-rotDeg * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); - const adjX = studioOffset.x * cos - studioOffset.y * sin; - const adjY = studioOffset.x * sin + studioOffset.y * cos; - const newX = Math.round(gsapPos.x + adjX); - const newY = Math.round(gsapPos.y + adjY); - const clearOffset = () => clearStudioPathOffset(selection.element); + const el = selection.element; + const origX = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-x") ?? "") || 0; + const origY = Number.parseFloat(el.getAttribute("data-hf-drag-initial-offset-y") ?? "") || 0; + const deltaX = studioOffset.x - origX; + const deltaY = studioOffset.y - origY; + const adjX = deltaX * cos - deltaY * sin; + const adjY = deltaX * sin + deltaY * cos; + // Use the GSAP base captured at drag start — the live gsapPos is corrupted + // by the draft's gsap.set() calls during drag. + const baseGsapX = + Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-x") ?? "") || gsapPos.x; + const baseGsapY = + Number.parseFloat(el.getAttribute("data-hf-drag-gsap-base-y") ?? "") || gsapPos.y; + const newX = Math.round(baseGsapX + adjX); + const newY = Math.round(baseGsapY + adjY); + // Restore the CSS offset to pre-drag value so the baked translate stays + // consistent with existing keyframes. The drag is captured in the new keyframe. + const restoreOffset = () => { + el.style.setProperty("--hf-studio-offset-x", `${origX}px`); + el.style.setProperty("--hf-studio-offset-y", `${origY}px`); + el.removeAttribute("data-hf-drag-initial-offset-x"); + el.removeAttribute("data-hf-drag-initial-offset-y"); + }; if (anim.keyframes) { const newId = await materializeIfDynamic(anim, iframe, callbacks.commitMutation, selection); const effectiveAnim = newId ? { ...anim, id: newId } : anim; const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); - await commitKeyframedPosition( + + // Check if current time is outside the tween's range — extend the tween + // to cover the playhead, remap existing keyframes, then add the new one. + const ct = usePlayerStore.getState().currentTime; + const ts = resolveTweenStart(effectiveAnim); + const td = resolveTweenDuration(effectiveAnim); + if (ts !== null && td > 0 && (ct < ts - 0.01 || ct > ts + td + 0.01)) { + await extendTweenAndAddKeyframe( + selection, + effectiveAnim, + { ...runtimeProps, x: newX, y: newY }, + ct, + ts, + td, + callbacks, + restoreOffset, + ); + } else { + await commitKeyframedPosition( + selection, + effectiveAnim, + { ...runtimeProps, x: newX, y: newY }, + callbacks, + restoreOffset, + ); + } + } else if (anim.method === "from" || anim.method === "fromTo") { + // from()/fromTo() — convert to keyframes in a single mutation, placing + // the dragged position at the 100% (rest) keyframe. A single mutation + // avoids the stable-id flip (from→to) that breaks chained mutations. + await callbacks.commitMutation( selection, - effectiveAnim, - { ...runtimeProps, x: newX, y: newY }, - callbacks, - clearOffset, + { + type: "convert-to-keyframes", + animationId: anim.id, + resolvedFromValues: { x: newX, y: newY }, + }, + { label: "Move layer (keyframe rest)", softReload: true, beforeReload: restoreOffset }, ); - } else 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()/set() — convert to keyframes then add at current percentage. const runtimeProps = readAllAnimatedProperties(iframe, selector, anim); await commitFlatViaKeyframes( selection, anim, { ...runtimeProps, x: newX, y: newY }, callbacks, - clearOffset, + restoreOffset, ); } } +/** + * Extend a tween's time range to cover `targetTime`, remap all existing + * keyframe percentages to preserve their absolute positions, then add + * a new keyframe at the target time. + */ +async function extendTweenAndAddKeyframe( + selection: DomEditSelection, + anim: GsapAnimation, + properties: Record, + targetTime: number, + tweenStart: number, + tweenDuration: number, + callbacks: GsapDragCommitCallbacks, + beforeReload?: () => void, +): Promise { + const tweenEnd = tweenStart + tweenDuration; + const newStart = Math.min(targetTime, tweenStart); + const newEnd = Math.max(targetTime, tweenEnd); + const newDuration = Math.max(0.01, newEnd - newStart); + + // Step 1: Remap all existing keyframes to preserve their absolute times + // in the new range, then add the new keyframe. + const existingKfs = anim.keyframes?.keyframes ?? []; + const remappedKfs: Array<{ percentage: number; properties: Record }> = + []; + for (const kf of existingKfs) { + const absTime = tweenStart + (kf.percentage / 100) * tweenDuration; + const newPct = Math.round(((absTime - newStart) / newDuration) * 1000) / 10; + remappedKfs.push({ percentage: newPct, properties: { ...kf.properties } }); + } + + // Add the new keyframe at the target time + const targetPct = Math.round(((targetTime - newStart) / newDuration) * 1000) / 10; + remappedKfs.push({ percentage: targetPct, properties }); + + // Sort and dedupe + remappedKfs.sort((a, b) => a.percentage - b.percentage); + + // Step 2: Delete the old tween and create a new one with the extended range + // and all remapped keyframes. Using delete + add-with-keyframes as an atomic pair. + await callbacks.commitMutation( + selection, + { type: "delete", animationId: anim.id }, + { label: "Extend tween range", skipReload: true }, + ); + + const selector = anim.targetSelector; + await callbacks.commitMutation( + selection, + { + type: "add-with-keyframes", + targetSelector: selector, + position: Math.round(newStart * 1000) / 1000, + duration: Math.round(newDuration * 1000) / 1000, + keyframes: remappedKfs, + }, + { label: `Move layer (extended keyframe)`, softReload: true, beforeReload }, + ); +} + // fallow-ignore-next-line complexity async function commitKeyframedPosition( selection: DomEditSelection, anim: GsapAnimation, properties: Record, callbacks: GsapDragCommitCallbacks, - beforeReload: () => void, + beforeReload?: () => void, ): Promise { const pct = computeCurrentPercentage(selection, anim); @@ -312,7 +421,7 @@ async function commitFlatViaKeyframes( anim: GsapAnimation, properties: Record, callbacks: GsapDragCommitCallbacks, - beforeReload: () => void, + beforeReload?: () => void, ): Promise { await callbacks.commitMutation( selection, @@ -334,65 +443,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/useAskAgentModal.ts b/packages/studio/src/hooks/useAskAgentModal.ts index ece327998..f88f3f261 100644 --- a/packages/studio/src/hooks/useAskAgentModal.ts +++ b/packages/studio/src/hooks/useAskAgentModal.ts @@ -3,6 +3,7 @@ import { copyTextToClipboard } from "../utils/clipboard"; import { readTagSnippetByTarget } from "../utils/sourcePatcher"; import { toProjectAbsolutePath, type AgentModalAnchorPoint } from "../utils/studioHelpers"; import { buildElementAgentPrompt, type DomEditSelection } from "../components/editor/domEditing"; +import { usePlayerStore } from "../player"; // ── Types ── @@ -11,7 +12,6 @@ export interface UseAskAgentModalParams { activeCompPath: string | null; projectDir: string | null; projectIdRef: React.MutableRefObject; - currentTime: number; showToast: (message: string, tone?: "error" | "info") => void; domEditSelectionRef: React.MutableRefObject; domEditSelection: DomEditSelection | null; @@ -23,7 +23,6 @@ export function useAskAgentModal({ activeCompPath, projectDir, projectIdRef, - currentTime, showToast, domEditSelectionRef, domEditSelection, @@ -91,7 +90,7 @@ export function useAskAgentModal({ const tagSnippet = agentPromptTagSnippet ?? domEditSelection.element.outerHTML; const prompt = buildElementAgentPrompt({ selection: domEditSelection, - currentTime, + currentTime: usePlayerStore.getState().currentTime, tagSnippet, selectionContext: agentPromptSelectionContext, userInstruction, @@ -115,7 +114,6 @@ export function useAskAgentModal({ activeCompPath, agentPromptSelectionContext, agentPromptTagSnippet, - currentTime, domEditSelection, projectDir, showToast, diff --git a/packages/studio/src/hooks/useDomEditSession.ts b/packages/studio/src/hooks/useDomEditSession.ts index 02013491e..f4fa2ce3c 100644 --- a/packages/studio/src/hooks/useDomEditSession.ts +++ b/packages/studio/src/hooks/useDomEditSession.ts @@ -50,7 +50,6 @@ export interface UseDomEditSessionParams { compositionLoading: boolean; previewIframeRef: React.MutableRefObject; timelineElements: TimelineElement[]; - currentTime: number; setSelectedTimelineElementId: (id: string | null) => void; setRightCollapsed: (collapsed: boolean) => void; setRightPanelTab: (tab: RightPanelTab) => void; @@ -92,7 +91,6 @@ export function useDomEditSession({ compositionLoading, previewIframeRef, timelineElements, - currentTime, setSelectedTimelineElementId, setRightCollapsed, setRightPanelTab, @@ -184,7 +182,6 @@ export function useDomEditSession({ activeCompPath, projectDir, projectIdRef, - currentTime, showToast, domEditSelectionRef, domEditSelection, @@ -272,6 +269,7 @@ export function useDomEditSession({ addGsapFromProperty, removeGsapFromProperty, addKeyframe, + addKeyframeBatch, removeKeyframe, convertToKeyframes, removeAllKeyframes, @@ -434,6 +432,7 @@ export function useDomEditSession({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, @@ -450,10 +449,10 @@ export function useDomEditSession({ addGsapFromProperty, removeGsapFromProperty, addKeyframe, + addKeyframeBatch, removeKeyframe, convertToKeyframes, removeAllKeyframes, - currentTime, handleDomManualEditsReset, selectedGsapAnimations, }); @@ -623,6 +622,7 @@ export function useDomEditSession({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, @@ -632,5 +632,12 @@ export function useDomEditSession({ handleUpdateArcSegment, invalidateGsapCache: bumpGsapCache, previewIframeRef, + commitMutation: async ( + mutation: Record, + options: { label: string; softReload?: boolean }, + ) => { + if (!domEditSelection) return; + await gsapCommitMutation(domEditSelection, mutation, options); + }, }; } diff --git a/packages/studio/src/hooks/useEnableKeyframes.ts b/packages/studio/src/hooks/useEnableKeyframes.ts new file mode 100644 index 000000000..3978ff871 --- /dev/null +++ b/packages/studio/src/hooks/useEnableKeyframes.ts @@ -0,0 +1,171 @@ +/** + * 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 }, + auto: true, + } as (typeof keyframes)[number]); + + if (session.commitMutation) { + await session.commitMutation( + { + type: "add-with-keyframes", + targetSelector: selector, + position: Math.round(elStart * 1000) / 1000, + duration: Math.round(elDuration * 1000) / 1000, + keyframes, + }, + { label: "Enable keyframes", softReload: true }, + ); + } else { + session.handleGsapAddAnimation("to"); + } + } + }, [sessionRef]); +} diff --git a/packages/studio/src/hooks/useGestureRecording.ts b/packages/studio/src/hooks/useGestureRecording.ts new file mode 100644 index 000000000..0c9877015 --- /dev/null +++ b/packages/studio/src/hooks/useGestureRecording.ts @@ -0,0 +1,340 @@ +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; +} + +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) { + // Opacity derived from total vertical displacement (absolute, not accumulated). + // Dragging down reduces opacity; dragging back up restores it. + nextOpacity = Math.max(0, Math.min(1, 1 - dy * 0.005)); + properties.opacity = nextOpacity; + if (scrollDelta !== 0) { + nextScale = Math.max(0.01, accumulatedState.scale + scrollDelta * 0.01); + properties.scale = nextScale; + } + } else if (modifiers.shift) { + 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 pointerElementOffsetRef = useRef({ x: 0, y: 0 }); + const runtimeRef = useRef<{ + seek: (t: number) => void; + set: (target: string, vars: Record) => void; + selector: string; + element: HTMLElement; + startTime: number; + maxSeekTime: number; + } | null>(null); + + const rafIdRef = useRef(0); + const samplesRef = useRef([]); + const trailRef = useRef>([]); + const cleanupRef = useRef<(() => void) | null>(null); + + // Unmount safety: cancel RAF + remove listeners if component tears down mid-recording. + useEffect(() => { + return () => { + cleanupRef.current?.(); + cleanupRef.current = null; + isRecordingRef.current = false; + }; + }, []); + + const startRecording = useCallback( + (element: HTMLElement, iframeEl: HTMLIFrameElement, elementEndTime?: number) => { + 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 */ + } + // When reapplyPathOffsets has run (translate restored to var-based), + // GSAP's cache was stripped — gsapX is 0 but the element is visually + // at CSSLeft + translate(offset). gsap.set wipes translate, so we need + // baseX to include the offset. When translate is "none" (GSAP owns it), + // gsapX already includes the baked offset — don't add. + const translateVal = element.style.translate ?? ""; + if (translateVal.includes("var(")) { + const offX = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-x")) || 0; + const offY = Number.parseFloat(element.style.getPropertyValue("--hf-studio-offset-y")) || 0; + baseX += offX; + baseY += offY; + } + accumulatedRef.current = { opacity: baseOpacity, scale: baseScaleVal, z: 0 }; + basePositionRef.current = { x: baseX, y: baseY }; + + const selector = element.id ? `#${element.id}` : null; + try { + const win = iframeEl.contentWindow as Window & { + gsap?: { set: (t: string, v: Record) => void }; + __timelines?: Record void; duration: () => number }>; + __player?: { getTime: () => number }; + }; + const tl = win?.__timelines ? Object.values(win.__timelines)[0] : null; + if (win?.gsap?.set && tl?.seek && selector) { + const tlDuration = tl.duration(); + runtimeRef.current = { + seek: tl.seek.bind(tl), + set: win.gsap.set.bind(win.gsap), + selector, + element, + startTime: win.__player?.getTime() ?? 0, + maxSeekTime: + elementEndTime != null && elementEndTime < tlDuration ? elementEndTime : tlDuration, + }; + } + } catch { + runtimeRef.current = null; + } + + const iframeRect = iframeEl.getBoundingClientRect(); + const doc = iframeEl.contentDocument; + const root = doc?.querySelector("[data-composition-id]") ?? doc?.documentElement; + const declaredWidth = Number(root?.getAttribute("data-width")) || 1920; + scaleRef.current = declaredWidth > 0 ? iframeRect.width / declaredWidth : 1; + + // Compute the offset between the element's visual center and the pointer + // so the element tracks the pointer exactly during recording (no jump). + const elRect = element.getBoundingClientRect(); + const elCenterViewport = { + x: elRect.left + elRect.width / 2, + y: elRect.top + elRect.height / 2, + }; + pointerElementOffsetRef.current = { x: 0, y: 0 }; // reset; set on first move + + const handlePointerMove = (e: PointerEvent) => { + pointerRef.current = { x: e.clientX, y: e.clientY }; + 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 }; + // Compute the offset between the pointer and the element center + // so the element follows the pointer without jumping. + pointerElementOffsetRef.current = { + x: e.clientX - elCenterViewport.x, + y: e.clientY - elCenterViewport.y, + }; + startCaptured = true; + hasMovedRef.current = true; + } + }; + 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.maxSeekTime, + ); + runtimeRef.current.seek(seekTime); + runtimeRef.current.set(runtimeRef.current.selector, { ...properties }); + runtimeRef.current.element.style.visibility = "visible"; + liveTime.notify(seekTime); + usePlayerStore.getState().setCurrentTime(seekTime); + } catch { + runtimeRef.current = null; + } + } + + samplesRef.current.push({ time, properties }); + trailRef.current.push({ x: pointerRef.current.x, y: pointerRef.current.y }); + setRecordingDuration(time); + rafIdRef.current = requestAnimationFrame(tick); + }; + + setIsRecording(true); + rafIdRef.current = requestAnimationFrame(tick); + + 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 = []; + trailRef.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..86bf4fe36 100644 --- a/packages/studio/src/hooks/useGsapScriptCommits.ts +++ b/packages/studio/src/hooks/useGsapScriptCommits.ts @@ -78,17 +78,34 @@ function updateKeyframeCacheFromParsed( selectionId: string | undefined, mutation: Record, ): void { - const { setKeyframeCache } = usePlayerStore.getState(); + const { setKeyframeCache, elements } = usePlayerStore.getState(); const idsWithKeyframes = new Set(); const merged = new Map(); for (const anim of animations) { const id = anim.targetSelector.match(/^#([\w-]+)/)?.[1]; if (!id || !anim.keyframes) continue; idsWithKeyframes.add(id); + + // Convert tween-relative percentages to clip-relative so diamonds + // render at the correct position within the timeline clip. + const tweenPos = typeof anim.position === "number" ? anim.position : 0; + const tweenDur = anim.duration ?? 1; + const timelineEl = elements.find( + (el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`, + ); + const elStart = timelineEl?.start ?? 0; + const elDuration = timelineEl?.duration ?? 4; + const clipKeyframes = anim.keyframes.keyframes.map((kf) => { + const absTime = tweenPos + (kf.percentage / 100) * tweenDur; + const clipPct = + elDuration > 0 ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 : kf.percentage; + return { ...kf, percentage: clipPct }; + }); + const existing = merged.get(id); if (existing) { const byPct = new Map(); - for (const kf of [...existing.keyframes, ...anim.keyframes.keyframes]) { + for (const kf of [...existing.keyframes, ...clipKeyframes]) { const prev = byPct.get(kf.percentage); if (prev) { prev.properties = { ...prev.properties, ...kf.properties }; @@ -99,11 +116,12 @@ function updateKeyframeCacheFromParsed( } existing.keyframes = Array.from(byPct.values()).sort((a, b) => a.percentage - b.percentage); } else { - merged.set(id, { ...anim.keyframes, keyframes: [...anim.keyframes.keyframes] }); + merged.set(id, { ...anim.keyframes, keyframes: clipKeyframes }); } } for (const [id, entry] of merged) { setKeyframeCache(`${targetPath}#${id}`, entry); + setKeyframeCache(id, entry); if (targetPath !== "index.html") setKeyframeCache(`index.html#${id}`, entry); } const targetId = @@ -201,12 +219,14 @@ export function useGsapScriptCommits({ }); } - onCacheInvalidate(); - if (result.after != null) { onFileContentChanged?.(targetPath, result.after); } + if (options.skipReload) return; + + // Write the keyframe cache immediately from the parsed response + // (synchronous — the timeline diamonds appear on the next render). if (result.parsed?.animations) { updateKeyframeCacheFromParsed( result.parsed.animations, @@ -216,8 +236,6 @@ export function useGsapScriptCommits({ ); } - if (options.skipReload) return; - options.beforeReload?.(); if (options.softReload && result.scriptText) { @@ -227,6 +245,11 @@ export function useGsapScriptCommits({ } else { reloadPreview(); } + + // Bump the cache version AFTER reload so the async re-fetch in + // useGsapAnimationsForElement reads the post-reload script, not + // the stale pre-reload version that would overwrite fresh data. + onCacheInvalidate(); }, [ projectIdRef, @@ -467,6 +490,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 +537,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 +627,7 @@ export function useGsapScriptCommits({ addGsapFromProperty, removeGsapFromProperty, addKeyframe, + addKeyframeBatch, removeKeyframe, convertToKeyframes, removeAllKeyframes, diff --git a/packages/studio/src/hooks/useGsapSelectionHandlers.ts b/packages/studio/src/hooks/useGsapSelectionHandlers.ts index 60f288d8e..aa8db6da4 100644 --- a/packages/studio/src/hooks/useGsapSelectionHandlers.ts +++ b/packages/studio/src/hooks/useGsapSelectionHandlers.ts @@ -1,5 +1,6 @@ import { useCallback } from "react"; import type { DomEditSelection } from "../components/editor/domEditing"; +import { usePlayerStore } from "../player"; /** * Thin useCallback wrappers that guard on `domEditSelection` before @@ -19,10 +20,10 @@ export function useGsapSelectionHandlers({ addGsapFromProperty, removeGsapFromProperty, addKeyframe, + addKeyframeBatch, removeKeyframe, convertToKeyframes, removeAllKeyframes, - currentTime, handleDomManualEditsReset, selectedGsapAnimations, }: { @@ -61,6 +62,12 @@ export function useGsapSelectionHandlers({ property: string, value: number | string, ) => void; + addKeyframeBatch: ( + sel: DomEditSelection, + animId: string, + percentage: number, + properties: Record, + ) => Promise; removeKeyframe: (sel: DomEditSelection, animId: string, percentage: number) => void; convertToKeyframes: ( sel: DomEditSelection, @@ -68,7 +75,7 @@ export function useGsapSelectionHandlers({ resolvedFromValues?: Record, ) => void; removeAllKeyframes: (sel: DomEditSelection, animId: string) => void; - currentTime: number; + handleDomManualEditsReset: (sel: DomEditSelection) => void; selectedGsapAnimations: { id: string; keyframes?: unknown }[]; }) { @@ -99,12 +106,12 @@ export function useGsapSelectionHandlers({ const handleGsapAddAnimation = useCallback( (method: "to" | "from" | "set" | "fromTo") => { if (!domEditSelection) return; - addGsapAnimation(domEditSelection, method, currentTime); + addGsapAnimation(domEditSelection, method, usePlayerStore.getState().currentTime); if (domEditSelection.element.hasAttribute("data-hf-studio-path-offset")) { handleDomManualEditsReset(domEditSelection); } }, - [domEditSelection, addGsapAnimation, currentTime, handleDomManualEditsReset], + [domEditSelection, addGsapAnimation, handleDomManualEditsReset], ); const handleGsapAddProperty = useCallback( @@ -155,6 +162,13 @@ export function useGsapSelectionHandlers({ [domEditSelection, addKeyframe], ); + const handleGsapAddKeyframeBatch = useCallback( + (animId: string, percentage: number, properties: Record) => { + if (!domEditSelection) return Promise.resolve(); + return addKeyframeBatch(domEditSelection, animId, percentage, properties); + }, + [domEditSelection, addKeyframeBatch], + ); const handleGsapRemoveKeyframe = useCallback( (animId: string, percentage: number) => { if (!domEditSelection) return; @@ -165,8 +179,8 @@ export function useGsapSelectionHandlers({ const handleGsapConvertToKeyframes = useCallback( (animId: string, resolvedFromValues?: Record) => { - if (!domEditSelection) return; - convertToKeyframes(domEditSelection, animId, resolvedFromValues); + if (!domEditSelection) return Promise.resolve(); + return convertToKeyframes(domEditSelection, animId, resolvedFromValues); }, [domEditSelection, convertToKeyframes], ); @@ -198,6 +212,7 @@ export function useGsapSelectionHandlers({ handleGsapAddFromProperty, handleGsapRemoveFromProperty, handleGsapAddKeyframe, + handleGsapAddKeyframeBatch, handleGsapRemoveKeyframe, handleGsapConvertToKeyframes, handleGsapRemoveAllKeyframes, diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index 4075c711c..1a3c1eeed 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -95,7 +95,12 @@ export function getAnimationsForElement( if (target.selector) matchers.add(target.selector); if (matchers.size === 0) return []; return animations.filter((a) => - a.targetSelector.split(",").some((part) => matchers.has(part.trim())), + a.targetSelector.split(",").some((part) => { + const trimmed = part.trim(); + if (matchers.has(trimmed)) return true; + const lastSimple = trimmed.split(/\s+/).pop(); + return lastSimple ? matchers.has(lastSimple) : false; + }), ); } @@ -251,6 +256,16 @@ export function useGsapAnimationsForElement( const elementId = target?.id ?? null; useEffect(() => { if (!elementId) return; + + // Resolve the element's time range from the player store so we can + // convert tween-relative keyframe percentages to clip-relative ones. + const { elements } = usePlayerStore.getState(); + const timelineEl = elements.find( + (el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`, + ); + const elStart = timelineEl?.start ?? 0; + const elDuration = timelineEl?.duration ?? 4; + const allKeyframes: GsapKeyframesData["keyframes"] = []; let format: GsapKeyframesData["format"] = "percentage"; let ease: string | undefined; @@ -258,12 +273,29 @@ export function useGsapAnimationsForElement( for (const anim of animations) { const kf = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim); if (!kf) continue; - allKeyframes.push(...kf.keyframes); + // Convert tween-relative percentages to clip-relative so diamonds + // render at the correct position within the timeline clip. + const tweenPos = typeof anim.position === "number" ? anim.position : 0; + const tweenDur = anim.duration ?? elDuration; + for (const k of kf.keyframes) { + const absTime = tweenPos + (k.percentage / 100) * tweenDur; + const clipPct = + elDuration > 0 + ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 + : k.percentage; + allKeyframes.push({ ...k, percentage: clipPct }); + } format = kf.format; if (kf.ease) ease = kf.ease; if (kf.easeEach) easeEach = kf.easeEach; } - if (allKeyframes.length === 0) return; + if (allKeyframes.length === 0) { + const { keyframeCache, setKeyframeCache } = usePlayerStore.getState(); + if (keyframeCache.has(`${sourceFile}#${elementId}`)) { + setKeyframeCache(`${sourceFile}#${elementId}`, undefined); + } + return; + } const dedupedKeyframes = deduplicateKeyframes(allKeyframes); const merged: GsapKeyframesData = { format, @@ -319,17 +351,34 @@ export function usePopulateKeyframeCacheForFile( setKeyframeCache(key, undefined); } } + const { elements } = usePlayerStore.getState(); const mergedByElement = new Map(); for (const anim of parsed.animations) { const id = extractIdFromSelector(anim.targetSelector); if (!id) continue; const kfData = anim.keyframes ?? synthesizeFlatTweenKeyframes(anim); if (!kfData) continue; + // Convert tween-relative percentages to clip-relative. + const tweenPos = typeof anim.position === "number" ? anim.position : 0; + const tweenDur = anim.duration ?? 1; + const timelineEl = elements.find( + (el) => el.domId === id || (el.key ?? el.id) === `${sf}#${id}`, + ); + const elStart = timelineEl?.start ?? 0; + const elDuration = timelineEl?.duration ?? 4; + const clipKeyframes = kfData.keyframes.map((kf) => { + const absTime = tweenPos + (kf.percentage / 100) * tweenDur; + const clipPct = + elDuration > 0 + ? Math.round(((absTime - elStart) / elDuration) * 1000) / 10 + : kf.percentage; + return { ...kf, percentage: clipPct }; + }); const existing = mergedByElement.get(id); if (existing) { - existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...kfData.keyframes]); + existing.keyframes = deduplicateKeyframes([...existing.keyframes, ...clipKeyframes]); } else { - mergedByElement.set(id, { ...kfData, keyframes: [...kfData.keyframes] }); + mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes }); } } for (const [id, kfData] of mergedByElement) { diff --git a/packages/studio/src/hooks/useStudioContextValue.ts b/packages/studio/src/hooks/useStudioContextValue.ts index 985025bbd..ab1d1c8a8 100644 --- a/packages/studio/src/hooks/useStudioContextValue.ts +++ b/packages/studio/src/hooks/useStudioContextValue.ts @@ -17,7 +17,6 @@ interface StudioContextInput { compositionLoading: boolean; refreshKey: number; setRefreshKey: React.Dispatch>; - currentTime: number; timelineElements: StudioContextValue["timelineElements"]; isPlaying: boolean; editHistory: { canUndo: boolean; canRedo: boolean; undoLabel: string; redoLabel: string }; @@ -50,7 +49,7 @@ export function buildStudioContextValue(input: StudioContextInput): StudioContex compositionLoading: input.compositionLoading, refreshKey: input.refreshKey, setRefreshKey: input.setRefreshKey, - currentTime: input.currentTime, + timelineElements: input.timelineElements, isPlaying: input.isPlaying, editHistory: input.editHistory, @@ -81,6 +80,7 @@ export function useInspectorState( rightCollapsed: boolean, isPlaying: boolean, domEditSelection: DomEditSelection | null, + isGestureRecording?: boolean, ): InspectorState { // fallow-ignore-next-line complexity return useMemo(() => { @@ -101,9 +101,10 @@ export function useInspectorState( inspectorPanelActive, inspectorButtonActive: STUDIO_INSPECTOR_PANELS_ENABLED && !rightCollapsed && inspectorPanelActive, - shouldShowSelectedDomBounds: inspectorPanelActive && !rightCollapsed && !isPlaying, + shouldShowSelectedDomBounds: + inspectorPanelActive && !rightCollapsed && !isPlaying && !isGestureRecording, }; - }, [rightPanelTab, rightCollapsed, isPlaying, domEditSelection]); + }, [rightPanelTab, rightCollapsed, isPlaying, domEditSelection, isGestureRecording]); } // fallow-ignore-next-line complexity diff --git a/packages/studio/src/hooks/useStudioUrlState.ts b/packages/studio/src/hooks/useStudioUrlState.ts index 5a8b9428f..6ab0b2f80 100644 --- a/packages/studio/src/hooks/useStudioUrlState.ts +++ b/packages/studio/src/hooks/useStudioUrlState.ts @@ -11,7 +11,6 @@ import { interface UseStudioUrlStateParams { projectId: string | null; activeCompPath: string | null; - currentTime: number; duration: number; isPlaying: boolean; compositionLoading: boolean; @@ -57,7 +56,6 @@ function replaceHash(nextHash: string) { export function useStudioUrlState({ projectId, activeCompPath, - currentTime, duration, isPlaying, compositionLoading, @@ -72,6 +70,7 @@ export function useStudioUrlState({ applyDomSelection, initialState, }: UseStudioUrlStateParams) { + const currentTime = usePlayerStore((s) => s.currentTime); const hydratedSeekRef = useRef(initialState.currentTime == null); const hydratedInitialTimeRef = useRef(initialState.currentTime == null); const hydratedSelectionRef = useRef(initialState.selection == null); diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index f2b7c032a..589b9b6f4 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -41,6 +41,7 @@ interface UseTimelineEditingOptions { previewIframeRef: React.RefObject; pendingTimelineEditPathRef: React.MutableRefObject>; uploadProjectFiles: (files: Iterable, dir?: string) => Promise; + isRecordingRef?: React.RefObject; } // ── Helpers ── @@ -174,6 +175,7 @@ export function useTimelineEditing({ previewIframeRef, pendingTimelineEditPathRef, uploadProjectFiles, + isRecordingRef, }: UseTimelineEditingOptions) { const projectIdRef = useRef(projectId); projectIdRef.current = projectId; @@ -187,6 +189,10 @@ export function useTimelineEditing({ label: string, buildPatches: PersistTimelineEditInput["buildPatches"], ): Promise => { + if (isRecordingRef?.current) { + showToast("Cannot edit timeline while recording", "error"); + return Promise.resolve(); + } const pid = projectIdRef.current; if (!pid) return Promise.resolve(); const queued = editQueueRef.current.then(() => @@ -213,6 +219,8 @@ export function useTimelineEditing({ writeProjectFile, domEditSaveTimestampRef, pendingTimelineEditPathRef, + showToast, + isRecordingRef, ], ); @@ -274,6 +282,10 @@ export function useTimelineEditing({ const handleTimelineElementDelete = useCallback( async (element: TimelineElement) => { + if (isRecordingRef?.current) { + showToast("Cannot edit timeline while recording", "error"); + return; + } const pid = projectIdRef.current; if (!pid) throw new Error("No active project"); const label = getTimelineElementLabel(element); @@ -338,6 +350,7 @@ export function useTimelineEditing({ writeProjectFile, domEditSaveTimestampRef, reloadPreview, + isRecordingRef, ], ); @@ -347,6 +360,10 @@ export function useTimelineEditing({ placement: Pick, durationOverride?: number, ) => { + if (isRecordingRef?.current) { + showToast("Cannot edit timeline while recording", "error"); + return; + } const pid = projectIdRef.current; if (!pid) throw new Error("No active project"); @@ -415,11 +432,16 @@ export function useTimelineEditing({ writeProjectFile, domEditSaveTimestampRef, reloadPreview, + isRecordingRef, ], ); const handleTimelineFileDrop = useCallback( async (files: File[], placement?: Pick) => { + if (isRecordingRef?.current) { + showToast("Cannot edit timeline while recording", "error"); + return; + } const pid = projectIdRef.current; if (!pid) return; const uploaded = await uploadProjectFiles(files); @@ -453,7 +475,14 @@ export function useTimelineEditing({ ); } }, - [activeCompPath, handleTimelineAssetDrop, timelineElements, uploadProjectFiles], + [ + activeCompPath, + handleTimelineAssetDrop, + timelineElements, + uploadProjectFiles, + isRecordingRef, + showToast, + ], ); const handleBlockedTimelineEdit = useCallback( @@ -468,6 +497,10 @@ export function useTimelineEditing({ const handleTimelineElementSplit = useCallback( async (element: TimelineElement, splitTime: number) => { + if (isRecordingRef?.current) { + showToast("Cannot edit timeline while recording", "error"); + return; + } const pid = projectIdRef.current; if (!pid) return; @@ -555,6 +588,7 @@ export function useTimelineEditing({ writeProjectFile, domEditSaveTimestampRef, reloadPreview, + isRecordingRef, ], ); diff --git a/packages/studio/src/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 (