import { useCallback, useEffect, useMemo, useRef, useState } from "react" import { CurvePoint, TransitionCurve } from "@/features/timeline/types/timeline-transition" import { cn } from "@/lib/utils" interface TransitionCurveEditorProps { curve: TransitionCurve onChange: (curve: TransitionCurve) => void width?: number height?: number className?: string isReadOnly?: boolean showGrid?: boolean showPresets?: boolean } // Предустановленные кривые const CURVE_PRESETS: Record = { linear: { type: "linear", points: [ { id: "start", x: 0, y: 0 }, { id: "end", x: 1, y: 1 }, ], }, "ease-in": { type: "ease-in", points: [ { id: "start", x: 0, y: 0 }, { id: "end", x: 1, y: 1, handleIn: { x: 0.42, y: 0 } }, ], }, "ease-out": { type: "ease-out", points: [ { id: "start", x: 0, y: 0, handleOut: { x: 0.58, y: 1 } }, { id: "end", x: 1, y: 1 }, ], }, "ease-in-out": { type: "ease-in-out", points: [ { id: "start", x: 0, y: 0, handleOut: { x: 0.42, y: 0 } }, { id: "end", x: 1, y: 1, handleIn: { x: 0.58, y: 1 } }, ], }, bounce: { type: "custom", points: [ { id: "start", x: 0, y: 0, handleOut: { x: 0.1, y: 0.2 } }, { id: "bounce1", x: 0.4, y: 1.2, handleIn: { x: 0.3, y: 1.2 }, handleOut: { x: 0.5, y: 1.2 } }, { id: "bounce2", x: 0.7, y: 0.9, handleIn: { x: 0.6, y: 0.9 }, handleOut: { x: 0.8, y: 0.9 } }, { id: "end", x: 1, y: 1, handleIn: { x: 0.9, y: 1 } }, ], }, elastic: { type: "custom", points: [ { id: "start", x: 0, y: 0, handleOut: { x: 0.1, y: -0.1 } }, { id: "elastic1", x: 0.3, y: -0.1, handleIn: { x: 0.2, y: -0.1 }, handleOut: { x: 0.4, y: -0.1 } }, { id: "elastic2", x: 0.6, y: 1.1, handleIn: { x: 0.5, y: 1.1 }, handleOut: { x: 0.7, y: 1.1 } }, { id: "end", x: 1, y: 1, handleIn: { x: 0.9, y: 1 } }, ], }, } /** * Компонент редактора кривой перехода */ export function TransitionCurveEditor({ curve, onChange, width = 300, height = 200, className, isReadOnly = false, showGrid = true, showPresets = true, }: TransitionCurveEditorProps) { const svgRef = useRef(null) const [selectedPointId, setSelectedPointId] = useState(null) const [isDragging, setIsDragging] = useState(false) const [dragType, setDragType] = useState<"point" | "handleIn" | "handleOut" | null>(null) // Отступы для визуализации const padding = 20 const gridSize = 20 const viewBoxWidth = width const viewBoxHeight = height // Преобразование координат из нормализованных (0-1) в координаты SVG const normToSvg = useCallback( (x: number, y: number) => { const svgX = padding + x * (viewBoxWidth - 2 * padding) const svgY = viewBoxHeight - padding - y * (viewBoxHeight - 2 * padding) return { x: svgX, y: svgY } }, [viewBoxWidth, viewBoxHeight], ) // Преобразование координат из SVG в нормализованные (0-1) const svgToNorm = useCallback( (x: number, y: number) => { const normX = (x - padding) / (viewBoxWidth - 2 * padding) const normY = 1 - (y - padding) / (viewBoxHeight - 2 * padding) return { x: Math.max(0, Math.min(1, normX)), y: Math.max(0, Math.min(1, normY)), } }, [viewBoxWidth, viewBoxHeight], ) // Генерация SVG path для кривой const curvePath = useMemo(() => { if (curve.points.length < 2) return "" let path = "" const firstPoint = normToSvg(curve.points[0].x, curve.points[0].y) path = `M ${firstPoint.x},${firstPoint.y}` for (let i = 1; i < curve.points.length; i++) { const prevPoint = curve.points[i - 1] const currPoint = curve.points[i] if (prevPoint.handleOut && currPoint.handleIn) { // Кубическая кривая Безье const p1 = normToSvg(prevPoint.x, prevPoint.y) const c1 = normToSvg(prevPoint.handleOut.x, prevPoint.handleOut.y) const c2 = normToSvg(currPoint.handleIn.x, currPoint.handleIn.y) const p2 = normToSvg(currPoint.x, currPoint.y) path += ` C ${c1.x},${c1.y} ${c2.x},${c2.y} ${p2.x},${p2.y}` } else { // Прямая линия const p2 = normToSvg(currPoint.x, currPoint.y) path += ` L ${p2.x},${p2.y}` } } return path }, [curve.points, normToSvg]) // Обработчик начала перетаскивания const handleMouseDown = useCallback( (e: React.MouseEvent, pointId: string, type: "point" | "handleIn" | "handleOut") => { if (isReadOnly) return e.preventDefault() setSelectedPointId(pointId) setDragType(type) setIsDragging(true) }, [isReadOnly], ) // Обработчик перемещения мыши const handleMouseMove = useCallback( (e: MouseEvent) => { if (!isDragging || !selectedPointId || !dragType || !svgRef.current) return const rect = svgRef.current.getBoundingClientRect() const x = e.clientX - rect.left const y = e.clientY - rect.top // Преобразуем в координаты SVG const svgX = (x / rect.width) * viewBoxWidth const svgY = (y / rect.height) * viewBoxHeight // Преобразуем в нормализованные координаты const norm = svgToNorm(svgX, svgY) // Обновляем точку const updatedPoints = curve.points.map((point) => { if (point.id !== selectedPointId) return point if (dragType === "point") { // Перемещаем точку и её handles const deltaX = norm.x - point.x const deltaY = norm.y - point.y return { ...point, x: norm.x, y: norm.y, handleIn: point.handleIn ? { x: point.handleIn.x + deltaX, y: point.handleIn.y + deltaY, } : undefined, handleOut: point.handleOut ? { x: point.handleOut.x + deltaX, y: point.handleOut.y + deltaY, } : undefined, } } if (dragType === "handleIn" && point.handleIn) { return { ...point, handleIn: { x: norm.x, y: norm.y }, } } if (dragType === "handleOut" && point.handleOut) { return { ...point, handleOut: { x: norm.x, y: norm.y }, } } return point }) onChange({ ...curve, type: "custom", points: updatedPoints, }) }, [isDragging, selectedPointId, dragType, curve, onChange, svgToNorm, viewBoxWidth, viewBoxHeight], ) // Обработчик отпускания мыши const handleMouseUp = useCallback(() => { setIsDragging(false) setDragType(null) }, []) // Добавление обработчиков событий useEffect(() => { if (isDragging) { document.addEventListener("mousemove", handleMouseMove) document.addEventListener("mouseup", handleMouseUp) return () => { document.removeEventListener("mousemove", handleMouseMove) document.removeEventListener("mouseup", handleMouseUp) } } }, [isDragging, handleMouseMove, handleMouseUp]) // Добавление новой точки const addPoint = useCallback( (e: React.MouseEvent) => { if (isReadOnly || !svgRef.current) return const rect = svgRef.current.getBoundingClientRect() const x = e.clientX - rect.left const y = e.clientY - rect.top const svgX = (x / rect.width) * viewBoxWidth const svgY = (y / rect.height) * viewBoxHeight const norm = svgToNorm(svgX, svgY) // Находим позицию для вставки let insertIndex = curve.points.length for (let i = 1; i < curve.points.length; i++) { if (norm.x < curve.points[i].x) { insertIndex = i break } } const newPoint: CurvePoint = { id: `point-${Date.now()}`, x: norm.x, y: norm.y, } const updatedPoints = [...curve.points] updatedPoints.splice(insertIndex, 0, newPoint) onChange({ ...curve, type: "custom", points: updatedPoints, }) }, [isReadOnly, curve, onChange, svgToNorm, viewBoxWidth, viewBoxHeight], ) // Удаление точки const deletePoint = useCallback( (pointId: string) => { if (isReadOnly || curve.points.length <= 2) return const updatedPoints = curve.points.filter((p) => p.id !== pointId) onChange({ ...curve, type: "custom", points: updatedPoints, }) }, [isReadOnly, curve, onChange], ) // Применение предустановки const applyPreset = useCallback( (presetName: string) => { const preset = CURVE_PRESETS[presetName] if (preset) { onChange(preset) } }, [onChange], ) return (
{/* Предустановки */} {showPresets && (
{Object.keys(CURVE_PRESETS).map((presetName) => ( ))}
)} {/* SVG редактор */} {/* Сетка */} {showGrid && ( {Array.from({ length: Math.floor(viewBoxWidth / gridSize) }).map((_, i) => ( ))} {Array.from({ length: Math.floor(viewBoxHeight / gridSize) }).map((_, i) => ( ))} )} {/* Оси */} {/* Кривая */} {/* Точки и handles */} {curve.points.map((point, _index) => { const svgPoint = normToSvg(point.x, point.y) const isSelected = selectedPointId === point.id return ( {/* Handle входа */} {point.handleIn && ( <> handleMouseDown(e, point.id, "handleIn")} /> )} {/* Handle выхода */} {point.handleOut && ( <> handleMouseDown(e, point.id, "handleOut")} /> )} {/* Точка */} handleMouseDown(e, point.id, "point")} onDoubleClick={(e) => { e.stopPropagation() deletePoint(point.id) }} /> {/* Подпись с координатами */} {isSelected && ( {`${point.x.toFixed(2)}, ${point.y.toFixed(2)}`} )} ) })} {/* Инструкции */} {!isReadOnly && (
Двойной клик - добавить точку • Двойной клик на точке - удалить • Перетаскивание - изменить положение
)}
) }