"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useEditor } from "@/editor/use-editor"; import { registerCanceller } from "@/editor/cancel-interaction"; import type { NormalizedCubicBezier } from "@/animation/types"; import { useKeyframeSelection } from "@/timeline/hooks/element/use-keyframe-selection"; import { applyGraphEditorCurvePreview, buildGraphEditorCurvePatches, resolveGraphEditorSelectionState, type GraphEditorSelectionState, } from "./session"; export function useGraphEditorController() { const editor = useEditor(); const renderTracks = useEditor( (currentEditor) => currentEditor.timeline.getPreviewTracks() ?? currentEditor.scenes.getActiveScene().tracks, ); const { selectedKeyframes } = useKeyframeSelection(); const [open, setOpen] = useState(false); const [activeComponentKey, setActiveComponentKey] = useState( null, ); const hasPreviewRef = useRef(false); const state = useMemo( () => resolveGraphEditorSelectionState({ tracks: renderTracks, selectedKeyframes, preferredComponentKey: activeComponentKey, }), [activeComponentKey, renderTracks, selectedKeyframes], ); const stateKey = state.status === "ready" ? `${state.trackId}:${state.elementId}:${state.activeComponentKey}:${state.segments .map( (segment) => `${segment.propertyPath}:${segment.keyframeId}:${segment.context.componentKey}`, ) .join("|")}` : `${state.status}:${state.reason}:${state.activeComponentKey ?? "none"}`; const previousStateKeyRef = useRef(stateKey); const discardPreview = useCallback(() => { if (!hasPreviewRef.current) { return; } editor.timeline.discardPreview(); hasPreviewRef.current = false; }, [editor]); useEffect(() => { if (hasPreviewRef.current && previousStateKeyRef.current !== stateKey) { discardPreview(); } previousStateKeyRef.current = stateKey; }, [discardPreview, stateKey]); useEffect(() => { if (!open) { return; } return registerCanceller({ fn: () => { discardPreview(); setOpen(false); }, }); }, [discardPreview, open]); const handleOpenChange = useCallback( (nextOpen: boolean) => { if (!nextOpen) { discardPreview(); } setOpen(nextOpen); }, [discardPreview], ); const handleActiveComponentKeyChange = useCallback( (nextComponentKey: string) => { discardPreview(); setActiveComponentKey(nextComponentKey); }, [discardPreview], ); const handlePreviewValue = useCallback( (nextValue: NormalizedCubicBezier) => { if (state.status !== "ready") { return; } const nextAnimations = state.segments.reduce( (animations, segment) => segment.allContexts.reduce( (nextAnimationsForSegment, context) => applyGraphEditorCurvePreview({ animations: nextAnimationsForSegment, context, cubicBezier: nextValue, referenceSpanValue: segment.referenceSpanValue, }), animations, ), state.element.animations, ); editor.timeline.previewElements({ updates: [ { trackId: state.trackId, elementId: state.elementId, updates: { animations: nextAnimations }, }, ], }); hasPreviewRef.current = true; }, [editor, state], ); const handleCommitValue = useCallback( (nextValue: NormalizedCubicBezier) => { if (state.status !== "ready") { return; } editor.timeline.updateKeyframeCurves({ keyframes: state.segments.flatMap((segment) => { const patches = buildGraphEditorCurvePatches({ context: segment.context, cubicBezier: nextValue, referenceSpanValue: segment.referenceSpanValue, }); if (!patches) { return []; } return segment.allContexts.flatMap((context) => patches.map(({ keyframeId, patch }) => ({ trackId: state.trackId, elementId: state.elementId, propertyPath: segment.propertyPath, componentKey: context.componentKey, keyframeId, patch, })), ); }), }); hasPreviewRef.current = false; }, [editor, state], ); return { open, onOpenChange: handleOpenChange, canOpen: state.status === "ready", tooltip: state.status === "ready" ? "Open graph editor" : state.message, state, onActiveComponentKeyChange: handleActiveComponentKeyChange, onPreviewValue: handlePreviewValue, onCommitValue: handleCommitValue, onCancelPreview: discardPreview, }; }