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 (
{activeElements.map((element) => { const isSelected = selectedElementId === element.id; if (element.type === 'video') { const imageSource = getImageSource(element); if (!imageSource) { return null; } return ( { if (node) { elementRefs.current[element.id] = node; } }} image={imageSource} // Use center position for x,y when offset is set x={element.x + element.width / 2} y={element.y + element.height / 2} width={element.width} height={element.height} // Set offset to center for proper rotation offsetX={element.width / 2} offsetY={element.height / 2} rotation={element.rotation || 0} draggable dragBoundFunc={createDragBoundFunc(element.id)} onClick={() => handleElementSelect(element.id)} onTap={() => handleElementSelect(element.id)} onDragMove={(e) => handleDragMove(element.id, e)} onDragEnd={(e) => handleDragEnd(element.id, e)} onTransform={() => handleTransform(element.id)} // Visual feedback for selection stroke={isSelected ? '#0066ff' : undefined} strokeWidth={isSelected ? 2 : 0} strokeScaleEnabled={false} /> ); } else if (element.type === 'text') { return ( { if (node) { elementRefs.current[element.id] = node; } }} text={element.text} x={element.x} y={element.y} fontSize={element.fontSize} fontStyle={element.fontWeight === 'bold' || element.fontWeight === 700 ? 'bold' : 'normal'} // ADD THIS LINE fontFamily="Arial" fill={element.fill} stroke={element.stroke} strokeWidth={element.strokeWidth} rotation={element.rotation || 0} // Center the text horizontally align="center" // Let text have natural width and height for multiline support wrap="word" draggable dragBoundFunc={createDragBoundFunc(element.id)} onClick={() => handleElementSelect(element.id)} onTap={() => handleElementSelect(element.id)} onDragMove={(e) => handleDragMove(element.id, e)} onDragEnd={(e) => handleDragEnd(element.id, e)} onTransform={() => handleTransform(element.id)} // Visual feedback for selection shadowColor={isSelected ? '#0066ff' : undefined} shadowBlur={isSelected ? 4 : 0} shadowOpacity={isSelected ? 0.3 : 0} /> ); } else if (element.type === 'image' && element.imageElement && element.isImageReady) { return ( { if (node) { elementRefs.current[element.id] = node; } }} image={element.imageElement} // Use center position for x,y when offset is set x={element.x + element.width / 2} y={element.y + element.height / 2} width={element.width} height={element.height} // Set offset to center for proper rotation offsetX={element.width / 2} offsetY={element.height / 2} rotation={element.rotation || 0} draggable dragBoundFunc={createDragBoundFunc(element.id)} onClick={() => handleElementSelect(element.id)} onTap={() => handleElementSelect(element.id)} onDragMove={(e) => handleDragMove(element.id, e)} onDragEnd={(e) => handleDragEnd(element.id, e)} onTransform={() => handleTransform(element.id)} // Visual feedback for selection stroke={isSelected ? '#0066ff' : undefined} strokeWidth={isSelected ? 2 : 0} strokeScaleEnabled={false} /> ); } return null; })} {/* Guide Lines Layer */} {guideLines.showVertical && ( )} {guideLines.showHorizontal && ( )} {/* Transformer for selected element */} { // Limit resize to prevent elements from becoming too small if (newBox.width < 20 || newBox.height < 20) { return oldBox; } return newBox; }} // Transformer styling - Figma-like appearance borderStroke="#0066ff" borderStrokeWidth={2} anchorStroke="#0066ff" anchorFill="white" anchorSize={14} anchorCornerRadius={2} // Enable only corner anchors for aspect ratio enabledAnchors={['top-left', 'top-right', 'bottom-right', 'bottom-left']} // Rotation handle rotateAnchorOffset={30} // Built-in Konva rotation snapping rotationSnaps={[0, 90, 180, 270]} rotationSnapTolerance={8} // Clear guide lines when transform ends onTransformEnd={() => { setGuideLines({ vertical: null, horizontal: null, showVertical: false, showHorizontal: false, }); }} // Style the rotation anchor to be circular anchorStyleFunc={(anchor) => { if (anchor.hasName('.rotater')) { // Make it circular by setting corner radius to half the width anchor.cornerRadius(anchor.width() / 2); anchor.fill('#0066ff'); anchor.stroke('white'); anchor.strokeWidth(1); } }} />
); }; export default VideoPreview;