'use client'; import * as React from 'react'; import { useEffect, useRef, useState } from 'react'; import type { ToolcraftTimelineKeyframeEasing } from '../../state/types'; import { areEasingControlPointsEqual, getEasingEditorControlPoints, interpolateTimelineEasingControlPoints, type TimelineBezierControlPoints, } from './timeline-easing-model'; import { easingEditorFrameHeight, easingEditorFrameOffsetX, easingEditorFrameOffsetY, easingEditorFrameWidth, easingEditorGridInset, easingEditorGridSize, getEasingEditorPoint, getEasingPreviewPath, } from './timeline-easing-geometry'; type TimelineCurveEditorDragTarget = 'p1' | 'p2'; const timelineEasingCurveAnimationDurationMs = 180; export function TimelineEasingEditor({ easing, onChange, }: { easing: ToolcraftTimelineKeyframeEasing; onChange: (easing: ToolcraftTimelineKeyframeEasing) => void; }): React.JSX.Element { const svgRef = useRef(null); const [dragTarget, setDragTarget] = useState(null); const targetControlPoints = getEasingEditorControlPoints(easing); const targetControlPointsKey = `${easing.type}:${targetControlPoints.join(',')}`; const [displayedControlPoints, setDisplayedControlPoints] = useState(targetControlPoints); const displayedControlPointsRef = useRef(displayedControlPoints); const animationFrameRef = useRef(null); const [isCurveAnimating, setIsCurveAnimating] = useState(false); const isStep = easing.type === 'step'; const renderedControlPoints = dragTarget ? targetControlPoints : displayedControlPoints; const [startX, startY] = getEasingEditorPoint(0, 0); const [endX, endY] = getEasingEditorPoint(1, 1); const [firstX, firstY] = getEasingEditorPoint(renderedControlPoints[0], renderedControlPoints[1]); const [secondX, secondY] = getEasingEditorPoint( renderedControlPoints[2], renderedControlPoints[3], ); const setAnimatedControlPoints = ( nextControlPoints: TimelineBezierControlPoints, ): void => { displayedControlPointsRef.current = nextControlPoints; setDisplayedControlPoints(nextControlPoints); }; const getEditorPointFromClient = ( clientX: number, clientY: number, ): { x: number; y: number } | null => { if (!svgRef.current) { return null; } const rect = svgRef.current.getBoundingClientRect(); if (!(rect.width > 0 && rect.height > 0)) { return null; } const scale = Math.min( rect.width / easingEditorFrameWidth, rect.height / easingEditorFrameHeight, ); if (!(scale > 0)) { return null; } const viewBoxLeft = rect.left + (rect.width - easingEditorFrameWidth * scale) / 2; const viewBoxTop = rect.top + (rect.height - easingEditorFrameHeight * scale) / 2; const pointerX = (clientX - viewBoxLeft) / scale - easingEditorFrameOffsetX; const pointerY = (clientY - viewBoxTop) / scale - easingEditorFrameOffsetY; return { x: Math.max(0, Math.min(1, (pointerX - easingEditorGridInset) / easingEditorGridSize)), y: Math.max(-1, Math.min(2, 1 - (pointerY - easingEditorGridInset) / easingEditorGridSize)), }; }; const updateControlPoint = ( target: TimelineCurveEditorDragTarget, nextPoint: { x: number; y: number }, ): void => { const nextControlPoints = [...targetControlPoints] as TimelineBezierControlPoints; if (target === 'p1') { nextControlPoints[0] = nextPoint.x; nextControlPoints[1] = nextPoint.y; } else { nextControlPoints[2] = nextPoint.x; nextControlPoints[3] = nextPoint.y; } onChange({ controlPoints: nextControlPoints, type: 'bezier' }); }; const handleDragMove = (event: PointerEvent): void => { if (!dragTarget) { return; } const nextPoint = getEditorPointFromClient(event.clientX, event.clientY); if (!nextPoint) { return; } updateControlPoint(dragTarget, nextPoint); }; useEffect(() => { const nextControlPoints = [...targetControlPoints] as TimelineBezierControlPoints; if (animationFrameRef.current !== null) { window.cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } if ( dragTarget || typeof window === 'undefined' || !window.requestAnimationFrame || window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ) { setIsCurveAnimating(false); setAnimatedControlPoints(nextControlPoints); return undefined; } const startControlPoints = displayedControlPointsRef.current; if (areEasingControlPointsEqual(startControlPoints, nextControlPoints)) { setIsCurveAnimating(false); setAnimatedControlPoints(nextControlPoints); return undefined; } let animationStartTime: number | null = null; setIsCurveAnimating(true); const tick = (timestamp: number): void => { animationStartTime ??= timestamp; const progress = Math.min( 1, (timestamp - animationStartTime) / timelineEasingCurveAnimationDurationMs, ); setAnimatedControlPoints( interpolateTimelineEasingControlPoints(startControlPoints, nextControlPoints, progress), ); if (progress < 1) { animationFrameRef.current = window.requestAnimationFrame(tick); return; } animationFrameRef.current = null; setIsCurveAnimating(false); setAnimatedControlPoints(nextControlPoints); }; animationFrameRef.current = window.requestAnimationFrame(tick); return () => { if (animationFrameRef.current !== null) { window.cancelAnimationFrame(animationFrameRef.current); animationFrameRef.current = null; } }; }, [dragTarget, targetControlPointsKey]); useEffect(() => { if (!dragTarget) { return; } const handlePointerUp = (): void => { setDragTarget(null); }; const previousCursor = document.body.style.cursor; document.body.style.cursor = 'grabbing'; window.addEventListener('pointermove', handleDragMove); window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointercancel', handlePointerUp); return () => { document.body.style.cursor = previousCursor; window.removeEventListener('pointermove', handleDragMove); window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp); }; }, [targetControlPoints, dragTarget, handleDragMove]); const startControlPointDrag = (target: TimelineCurveEditorDragTarget) => (event: React.PointerEvent) => { event.preventDefault(); event.stopPropagation(); setDragTarget(target); }; const startCurveDrag = (event: React.PointerEvent): void => { event.preventDefault(); event.stopPropagation(); const point = getEditorPointFromClient(event.clientX, event.clientY); if (!point) { return; } setDragTarget(point.x <= 0.5 ? 'p1' : 'p2'); }; return ( {[0.25, 0.5, 0.75].map((point) => { const [gridX] = getEasingEditorPoint(point, 0); const [, gridY] = getEasingEditorPoint(0, point); return ( ); })} {isStep ? ( <> ) : ( <> )} ); }