import { useMitt } from '@/plugins/MittContext'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Image, Layer, Line, Stage, Text, Transformer } from 'react-konva'; const VideoPreview = ({ // Dimensions dimensions, // Timeline state currentTime, totalDuration, isPlaying, status, // Export state isExporting, exportProgress, exportStatus, // Data timelineElements, activeElements, videoElements, loadedVideos, videoStates, ffmpegCommand, // Event handlers handlePlay, handlePause, handleReset, handleSeek, copyFFmpegCommand, exportVideo, onElementUpdate, // New prop for updating element properties // Refs layerRef, }) => { const emitter = useMitt(); // Selection state const [selectedElementId, setSelectedElementId] = useState(null); const transformerRef = useRef(null); const stageRef = useRef(null); // Refs for each element to connect with transformer const elementRefs = useRef({}); // Guide lines state const [guideLines, setGuideLines] = useState({ vertical: null, horizontal: null, showVertical: false, showHorizontal: false, }); // Snap settings const POSITION_SNAP_THRESHOLD = 10; // Pixels within which to snap to center // Function to determine which image source to use for videos const getImageSource = (element) => { const isVideoActive = videoStates[element.id] && isPlaying; if (isVideoActive && element.videoElement && element.isVideoReady) { return element.videoElement; } else if (element.posterImage && element.isVideoPoster) { return element.posterImage; } return null; }; // Check if element uses center-offset positioning const usesCenterPositioning = (elementType) => { return elementType === 'video' || elementType === 'image'; }; // Check if position should snap to center and calculate guide lines const calculateSnapAndGuides = (elementId, newX, newY, width, height) => { const centerX = dimensions.width / 2; const centerY = dimensions.height / 2; // Calculate element center const elementCenterX = newX + width / 2; const elementCenterY = newY + height / 2; let snapX = newX; let snapY = newY; let showVertical = false; let showHorizontal = false; let verticalLine = null; let horizontalLine = null; // Check vertical center snap if (Math.abs(elementCenterX - centerX) < POSITION_SNAP_THRESHOLD) { snapX = centerX - width / 2; showVertical = true; verticalLine = centerX; } // Check horizontal center snap if (Math.abs(elementCenterY - centerY) < POSITION_SNAP_THRESHOLD) { snapY = centerY - height / 2; showHorizontal = true; horizontalLine = centerY; } return { x: snapX, y: snapY, guideLines: { vertical: verticalLine, horizontal: horizontalLine, showVertical, showHorizontal, }, }; }; // Handle element selection const handleElementSelect = useCallback( (elementId) => { setSelectedElementId(elementId); // Find the selected element const element = timelineElements.find((el) => el.id === elementId); // If it's a text element, emit text-element-selected event if (element && element.type === 'text') { emitter.emit('text-element-selected', element); } // Clear guide lines when selecting setGuideLines({ vertical: null, horizontal: null, showVertical: false, showHorizontal: false, }); }, [emitter, timelineElements], ); // Handle clicking on empty space to deselect const handleStageClick = useCallback((e) => { // If clicking on stage (not on an element), deselect if (e.target === e.target.getStage()) { setSelectedElementId(null); setGuideLines({ vertical: null, horizontal: null, showVertical: false, showHorizontal: false, }); } }, []); // Handle drag events with snapping const handleDragMove = useCallback( (elementId, e) => { const node = e.target; const element = timelineElements.find((el) => el.id === elementId); if (!element) return; const width = node.width() * node.scaleX(); const height = node.height() * node.scaleY(); let topLeftX, topLeftY; if (usesCenterPositioning(element.type)) { // For center-positioned elements (video/image), convert center to top-left const elementCenterX = node.x(); const elementCenterY = node.y(); topLeftX = elementCenterX - width / 2; topLeftY = elementCenterY - height / 2; } else { // For top-left positioned elements (text) topLeftX = node.x(); topLeftY = node.y(); } const snapResult = calculateSnapAndGuides(elementId, topLeftX, topLeftY, width, height); // Update guide lines setGuideLines(snapResult.guideLines); // Update state during drag if (onElementUpdate) { onElementUpdate(elementId, { x: snapResult.x, y: snapResult.y, }); } }, [onElementUpdate, dimensions.width, dimensions.height, timelineElements], ); // Create drag bound function for real-time snapping const createDragBoundFunc = useCallback( (elementId) => { return (pos) => { const element = timelineElements.find((el) => el.id === elementId); if (!element) return pos; const node = elementRefs.current[elementId]; if (!node) return pos; const width = node.width() * node.scaleX(); const height = node.height() * node.scaleY(); let topLeftX, topLeftY; if (usesCenterPositioning(element.type)) { // Convert center position to top-left for snapping calculations topLeftX = pos.x - width / 2; topLeftY = pos.y - height / 2; } else { topLeftX = pos.x; topLeftY = pos.y; } const snapResult = calculateSnapAndGuides(elementId, topLeftX, topLeftY, width, height); if (usesCenterPositioning(element.type)) { // Convert back to center position return { x: snapResult.x + width / 2, y: snapResult.y + height / 2, }; } else { return { x: snapResult.x, y: snapResult.y, }; } }; }, [timelineElements, dimensions.width, dimensions.height], ); const handleDragEnd = useCallback( (elementId, e) => { const node = e.target; const element = timelineElements.find((el) => el.id === elementId); if (!element) return; // Clear guide lines when drag ends setGuideLines({ vertical: null, horizontal: null, showVertical: false, showHorizontal: false, }); // Final position update const width = node.width() * node.scaleX(); const height = node.height() * node.scaleY(); let finalX, finalY; if (usesCenterPositioning(element.type)) { finalX = node.x() - width / 2; finalY = node.y() - height / 2; } else { finalX = node.x(); finalY = node.y(); } if (onElementUpdate) { onElementUpdate(elementId, { x: finalX, y: finalY, }); } }, [onElementUpdate, timelineElements], ); // Handle transform events (scale, rotate) with snapping - USES NATIVE KONVA ROTATION SNAPPING const handleTransform = useCallback( (elementId) => { const node = elementRefs.current[elementId]; const element = timelineElements.find((el) => el.id === elementId); if (!node || !onElementUpdate || !element) return; // Get rotation - Konva handles snapping automatically with rotationSnaps const rotation = node.rotation(); // Get the scale values from Konva const scaleX = node.scaleX(); const scaleY = node.scaleY(); let newWidth, newHeight; if (element.type === 'text') { // For text, allow free scaling newWidth = node.width() * scaleX; newHeight = node.height() * scaleY; } else { // For images/videos, maintain aspect ratio by using the larger scale const scale = Math.max(Math.abs(scaleX), Math.abs(scaleY)); newWidth = node.width() * scale; newHeight = node.height() * scale; // Reset scale to 1 and update dimensions to maintain aspect ratio node.scaleX(1); node.scaleY(1); node.width(newWidth); node.height(newHeight); // Update offset for center rotation node.offsetX(newWidth / 2); node.offsetY(newHeight / 2); } // Calculate position for snapping let topLeftX, topLeftY; if (usesCenterPositioning(element.type)) { // Convert center position to top-left for snapping const centerX = node.x(); const centerY = node.y(); topLeftX = centerX - newWidth / 2; topLeftY = centerY - newHeight / 2; } else { // Use position directly for text topLeftX = node.x(); topLeftY = node.y(); } // Check for position snapping during transform (but be less aggressive during rotation) const isRotating = Math.abs(rotation % 90) > 5; // Not close to perpendicular if (!isRotating) { const snapResult = calculateSnapAndGuides(elementId, topLeftX, topLeftY, newWidth, newHeight); if (Math.abs(snapResult.x - topLeftX) > 5 || Math.abs(snapResult.y - topLeftY) > 5) { if (usesCenterPositioning(element.type)) { // Convert back to center position const newCenterX = snapResult.x + newWidth / 2; const newCenterY = snapResult.y + newHeight / 2; node.x(newCenterX); node.y(newCenterY); } else { // Apply directly for text node.x(snapResult.x); node.y(snapResult.y); } setGuideLines(snapResult.guideLines); topLeftX = snapResult.x; topLeftY = snapResult.y; } } else { // Clear guide lines during rotation setGuideLines({ vertical: null, horizontal: null, showVertical: false, showHorizontal: false, }); } // Update state with the final calculated values const finalTransform = { x: topLeftX, y: topLeftY, width: newWidth, height: newHeight, rotation: rotation, }; onElementUpdate(elementId, finalTransform); }, [onElementUpdate, dimensions.width, dimensions.height, timelineElements], ); // Update transformer when selection changes useEffect(() => { if (transformerRef.current) { const selectedNode = selectedElementId ? elementRefs.current[selectedElementId] : null; if (selectedNode) { transformerRef.current.nodes([selectedNode]); transformerRef.current.getLayer().batchDraw(); } else { transformerRef.current.nodes([]); } } }, [selectedElementId, activeElements]); return (