/** * Manages gesture recording state and commit logic for the Studio. * Extracted from App.tsx to keep file sizes under the 600-line limit. */ import { useState, useCallback, useRef, useEffect } from "react"; import { editLog } from "../utils/editDebugLog"; import { useGestureRecording } from "./useGestureRecording"; import { simplifyGestureSamples } from "../utils/rdpSimplify"; import { fitEasesFromVelocity } from "../utils/velocityEaseFitter"; import { smoothGestureKeyframes } from "../utils/gestureSmoother"; import { usePlayerStore } from "../player"; import type { DomEditSelection } from "../components/editor/domEditing"; import type { GsapAnimation } from "@hyperframes/core/gsap-parser"; import { roundTo3 } from "../utils/rounding"; import { classifyPropertyGroup } from "@hyperframes/core/gsap-parser"; type RecordedKeyframe = { percentage: number; properties: Record; ease?: string; }; /** * Split recorded keyframes into one keyframe-set per property group (position / * scale / rotation / …), each keyframe carrying only that group's props. * * A mixed-prop gesture (e.g. x/y + opacity) emitted as ONE add-with-keyframes * mutation parses back as an untagged legacy mixed tween, which breaks the * position-only drag intercept (it can't find a pure position tween to edit). * Emitting one tween per group keeps the position tween tagged and editable. * Keyframes with no prop in a group are dropped from that group's set. */ // fallow-ignore-next-line complexity function partitionKeyframesByGroup(keyframes: RecordedKeyframe[]): RecordedKeyframe[][] { // Preserve first-seen group order for deterministic, stable mutation ordering. const groupOrder: string[] = []; const byGroup = new Map(); for (const kf of keyframes) { const perGroup = new Map>(); for (const [key, value] of Object.entries(kf.properties)) { const group = classifyPropertyGroup(key); let props = perGroup.get(group); if (!props) { props = {}; perGroup.set(group, props); } props[key] = value; } for (const [group, props] of perGroup) { let set = byGroup.get(group); if (!set) { set = []; byGroup.set(group, set); groupOrder.push(group); } set.push({ percentage: kf.percentage, properties: props, ...(kf.ease ? { ease: kf.ease } : {}), }); } } return groupOrder.map((group) => byGroup.get(group)!); } // Minimal subset of the session used by gesture commit interface GestureSessionRef { domEditSelection: DomEditSelection | null; selectedGsapAnimations?: GsapAnimation[]; commitMutation?: ( mutation: Record, options: { label: string; softReload?: boolean }, ) => Promise; } interface UseGestureCommitParams { domEditSessionRef: React.MutableRefObject; previewIframeRef: React.RefObject; showToast: (message: string, tone?: "error" | "info") => void; isGestureRecordingRef: React.MutableRefObject; } export interface UseGestureCommitResult { gestureState: "idle" | "recording"; gestureRecording: ReturnType; handleToggleRecording: () => void; } // fallow-ignore-next-line complexity export function useGestureCommit({ domEditSessionRef, previewIframeRef, showToast, isGestureRecordingRef, }: UseGestureCommitParams): UseGestureCommitResult { const gestureRecording = useGestureRecording(); const [gestureState, setGestureState] = useState<"idle" | "recording">("idle"); const gestureStateRef = useRef<"idle" | "recording">("idle"); const recordingAutoStopRef = useRef>(undefined); const recordingStartTimeRef = useRef(0); const commitInFlightRef = useRef(false); // Capture selection at recording start so commit always targets the recorded element, // even if the user's selection changes mid-recording. const capturedSelectionRef = useRef(null); // 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 = capturedSelectionRef.current; if (!sel) { if (frozenSamples.length > 2) { showToast("Selection lost during recording", "error"); } return; } const duration = frozenSamples.length > 0 ? (frozenSamples[frozenSamples.length - 1]?.time ?? 0) : 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; } // Per-property epsilon: small-range properties (opacity 0–1, scale ~0.01–10) // need a much tighter tolerance than positional properties (x/y in px). // fallow-ignore-next-line complexity const simplified = simplifyGestureSamples(frozenSamples, duration, (key) => { if (key === "opacity") return 0.01; if (key === "scale" || key === "scaleX" || key === "scaleY") return 0.01; return 5; }); const sortedPcts = Array.from(simplified.keys()).sort((a, b) => a - b); // Ensure a 0% keyframe exists with the element's start-of-recording position if (!simplified.has(0) && frozenSamples.length > 0) { simplified.set(0, frozenSamples[0]!.properties); if (!sortedPcts.includes(0)) sortedPcts.unshift(0); } 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 rawKeyframes = sortedPcts.map((pct) => ({ percentage: pct, properties: simplified.get(pct) as Record, })); const smoothed = smoothGestureKeyframes(rawKeyframes, 3); const keyframes = fitEasesFromVelocity(smoothed, frozenSamples, duration); const hasPositionProps = keyframes.some((kf) => Object.keys(kf.properties).some((k) => classifyPropertyGroup(k) === "position"), ); const allAnims = liveSession.selectedGsapAnimations ?? []; const existingPositionTween = hasPositionProps ? allAnims.find((a) => a.propertyGroup === "position" && a.targetSelector === selector) : undefined; if (existingPositionTween) { if (existingPositionTween.method === "set") { // A `set` is a static hold, not a tween to merge into — replace it with // the recorded motion (which already starts from the set's position). await liveSession.commitMutation( { type: "replace-with-keyframes", animationId: existingPositionTween.id, targetSelector: selector, position: roundTo3(recStart), duration: roundTo3(duration), keyframes, }, { label: "Gesture recording (replace set)", softReload: true }, ); } else { const tweenStart = existingPositionTween.resolvedStart ?? 0; const tweenDur = existingPositionTween.duration ?? duration; const tweenEnd = tweenStart + tweenDur; const recEnd = recStart + duration; // Only merge if the recording overlaps the existing tween's time range. // No overlap → fall through to add-with-keyframes (creates a separate tween). const overlaps = recStart < tweenEnd + 0.05 && recEnd > tweenStart - 0.05; if (overlaps) { const existingKfs = existingPositionTween.keyframes?.keyframes ?? []; const rangeStartPct = tweenDur > 0 ? Math.max(0, ((recStart - tweenStart) / tweenDur) * 100) : 0; const rangeEndPct = tweenDur > 0 ? Math.min(100, ((recEnd - tweenStart) / tweenDur) * 100) : 100; const preserved = existingKfs .filter( (kf) => kf.percentage < rangeStartPct - 0.5 || kf.percentage > rangeEndPct + 0.5, ) .map((kf) => ({ percentage: kf.percentage, properties: kf.properties, ...(kf.ease ? { ease: kf.ease } : {}), })); const mapped = keyframes.map((kf) => ({ percentage: rangeStartPct + (kf.percentage / 100) * (rangeEndPct - rangeStartPct), properties: kf.properties, ...(kf.ease ? { ease: kf.ease } : {}), })); const merged = [...preserved, ...mapped].sort((a, b) => a.percentage - b.percentage); await liveSession.commitMutation( { type: "replace-with-keyframes", animationId: existingPositionTween.id, targetSelector: selector, position: typeof existingPositionTween.position === "number" ? existingPositionTween.position : tweenStart, duration: tweenDur, keyframes: merged, }, { label: "Gesture recording (merge)", softReload: true }, ); } else { // Emit one tween per property group so a mixed-prop gesture (e.g. // x/y + opacity) doesn't collapse into an untagged legacy mixed // tween that the position-only drag intercept can't edit. for (const groupKfs of partitionKeyframesByGroup(keyframes)) { await liveSession.commitMutation( { type: "add-with-keyframes", targetSelector: selector, position: roundTo3(recStart), duration: roundTo3(duration), keyframes: groupKfs, // Linear fallback: the velocity fitter assigns a per-keyframe // ease to non-constant segments and intentionally leaves // constant-speed segments undefined → they must stay linear, // not inherit a sigmoid. easeEach: "none", }, { label: "Gesture recording (new range)", softReload: true }, ); } } } } else { // No existing tween — same per-group split as the new-range branch above. for (const groupKfs of partitionKeyframesByGroup(keyframes)) { await liveSession.commitMutation( { type: "add-with-keyframes", targetSelector: selector, position: roundTo3(recStart), duration: roundTo3(duration), keyframes: groupKfs, // Linear fallback (see above) — constant-speed segments stay linear. easeEach: "none", }, { label: "Gesture recording", softReload: true }, ); } } } showToast(`Recorded ${sortedPcts.length} keyframes`, "info"); } catch (err) { console.error("[GR:error]", err); showToast(`Gesture commit failed: ${err}`, "error"); } finally { store.requestSeek(recordingStartTimeRef.current); gestureRecording.clearSamples(); setGestureState("idle"); commitInFlightRef.current = false; } }, [gestureRecording, showToast, isGestureRecordingRef, domEditSessionRef]); // fallow-ignore-next-line complexity const handleToggleRecording = useCallback(() => { editLog("gesture", gestureStateRef.current === "recording" ? "stop" : "start", { id: domEditSessionRef.current.domEditSelection?.id, }); 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; capturedSelectionRef.current = sel; 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, previewIframeRef, domEditSessionRef, isGestureRecordingRef, ]); return { gestureState, gestureRecording, handleToggleRecording }; }