From 9a81d90bfb80c63b366f41a77d93c18fada69643 Mon Sep 17 00:00:00 2001 From: ct Date: Mon, 16 Jun 2025 16:05:27 +0800 Subject: [PATCH] Update --- .../partials/canvas/sample-timeline-data.jsx | 10 +- .../editor/partials/canvas/video-editor.jsx | 50 +- .../editor/partials/canvas/video-preview.jsx | 484 +++++++++++++++++- 3 files changed, 508 insertions(+), 36 deletions(-) diff --git a/resources/js/modules/editor/partials/canvas/sample-timeline-data.jsx b/resources/js/modules/editor/partials/canvas/sample-timeline-data.jsx index 46b2716..81a75f8 100644 --- a/resources/js/modules/editor/partials/canvas/sample-timeline-data.jsx +++ b/resources/js/modules/editor/partials/canvas/sample-timeline-data.jsx @@ -14,6 +14,7 @@ const sampleTimelineElements = [ y: 50, width: 300, height: 200, + rotation: 0, }, { id: '2', @@ -30,6 +31,7 @@ const sampleTimelineElements = [ y: 100, width: 250, height: 150, + rotation: 0, }, { id: '3', @@ -46,20 +48,22 @@ const sampleTimelineElements = [ y: 200, width: 280, height: 180, + rotation: 0, }, { id: '4', type: 'text', text: 'Welcome to the Timeline!', - startTime: 1, + startTime: 0, layer: 2, - duration: 3, + duration: 4, x: 50, y: 600, fontSize: 24, fill: 'white', stroke: 'black', strokeWidth: 1, + rotation: 0, }, { id: '5', @@ -74,6 +78,7 @@ const sampleTimelineElements = [ fill: 'yellow', stroke: 'red', strokeWidth: 2, + rotation: 0, }, { id: '6', @@ -88,6 +93,7 @@ const sampleTimelineElements = [ y: 200, width: 280, height: 180, + rotation: 0, }, ]; diff --git a/resources/js/modules/editor/partials/canvas/video-editor.jsx b/resources/js/modules/editor/partials/canvas/video-editor.jsx index 4ebc7d0..cd3f191 100644 --- a/resources/js/modules/editor/partials/canvas/video-editor.jsx +++ b/resources/js/modules/editor/partials/canvas/video-editor.jsx @@ -1,5 +1,3 @@ -// TODO: I moved the sample timeline data to a dedicated file, and delayed the loading to 1 sec with useEffect. as such, alot of the ogics are broken. I need to make sure the delayed timeline should work like normal - import { useMitt } from '@/plugins/MittContext'; import useVideoEditorStore from '@/stores/VideoEditorStore'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -16,8 +14,6 @@ const VideoEditor = ({ width, height }) => { }); const [timelineElements, setTimelineElements] = useState([]); - - // 🔧 FIX: Add ref to solve closure issue const timelineElementsRef = useRef([]); const lastUpdateRef = useRef(0); @@ -39,12 +35,12 @@ const VideoEditor = ({ width, height }) => { const FPS_INTERVAL = 1000 / 30; // 30 FPS - // 🔧 FIX: Keep ref synced with state + // Keep ref synced with state useEffect(() => { timelineElementsRef.current = timelineElements; }, [timelineElements]); - // ✅ FIX 1: Use useEffect to automatically setup videos when timeline loads + // Initialize timeline useEffect(() => { initTimeline(); }, []); @@ -58,7 +54,6 @@ const VideoEditor = ({ width, height }) => { }); }, []); - // Add this useEffect to resolve the promise when timeline updates useEffect(() => { if (timelineUpdateResolverRef.current && timelineElements.length > 0) { timelineUpdateResolverRef.current(); @@ -70,13 +65,35 @@ const VideoEditor = ({ width, height }) => { cleanupVideos(videoElements); setTimelineElementsAsync(sampleTimelineElements).then(() => { showConsoleLogs && console.log('Loaded sample timeline'); - setupVideos(); - setupImages(); // Add image setup + setupImages(); }); }; - // ✅ NEW: Setup function for image elements + // NEW: Handle element transformations (position, scale, rotation) + const handleElementUpdate = useCallback( + (elementId, updates) => { + setTimelineElements((prev) => + prev.map((element) => { + if (element.id === elementId) { + return { + ...element, + ...updates, + }; + } + return element; + }), + ); + + // Force redraw if not playing + if (!isPlaying && layerRef.current) { + layerRef.current.batchDraw(); + } + }, + [isPlaying], + ); + + // Setup function for image elements const setupImages = () => { showConsoleLogs && console.log('setupImages'); @@ -108,7 +125,6 @@ const VideoEditor = ({ width, height }) => { let scaledWidth = imgWidth; let scaledHeight = imgHeight; - // Scale down if image is larger than canvas if (imgWidth > maxWidth || imgHeight > maxHeight) { const scaleX = maxWidth / imgWidth; const scaleY = maxHeight / imgHeight; @@ -118,7 +134,6 @@ const VideoEditor = ({ width, height }) => { scaledHeight = imgHeight * scale; } - // Use provided position or center the image const centeredX = element.x || (maxWidth - scaledWidth) / 2; const centeredY = element.y || (maxHeight - scaledHeight) / 2; @@ -131,6 +146,7 @@ const VideoEditor = ({ width, height }) => { y: centeredY, width: element.width || scaledWidth, height: element.height || scaledHeight, + rotation: element.rotation || 0, imageElement: img, isImageReady: true, }; @@ -153,7 +169,6 @@ const VideoEditor = ({ width, height }) => { }); }; - // ✅ FIX 3: Auto-update status when videos load useEffect(() => { setupVideoStatus(); }, [timelineElements, loadedVideos]); @@ -164,7 +179,6 @@ const VideoEditor = ({ width, height }) => { const totalDuration = Math.max(...timelineElements.map((el) => el.startTime + el.duration)); - // Use the FFmpeg hook const { isExporting, exportProgress, exportStatus, ffmpegCommand, copyFFmpegCommand, exportVideo } = useVideoExport({ timelineElements, dimensions, @@ -174,7 +188,6 @@ const VideoEditor = ({ width, height }) => { const setupVideos = () => { showConsoleLogs && console.log('setupVideos'); - // 🔧 FIX: Read from ref instead of state to get latest data const elements = timelineElementsRef.current; if (elements.length === 0) { @@ -246,6 +259,7 @@ const VideoEditor = ({ width, height }) => { y: centeredY, width: scaledWidth, height: scaledHeight, + rotation: element.rotation || 0, posterImage: posterImg, isVideoPoster: true, }; @@ -311,7 +325,6 @@ const VideoEditor = ({ width, height }) => { }; const setupVideoStatus = () => { - // Update to count both videos and images const mediaCount = timelineElements.filter((el) => el.type === 'video' || el.type === 'image').length; if (loadedVideos.size === mediaCount && mediaCount > 0) { setStatus('Ready to play'); @@ -322,7 +335,6 @@ const VideoEditor = ({ width, height }) => { } }; - // FIXED: Removed currentTime dependency to prevent excessive recreation const handlePause = useCallback(() => { if (isPlaying) { setIsPlaying(false); @@ -412,7 +424,6 @@ const VideoEditor = ({ width, height }) => { setVideoStates(desiredStates); }, [currentTime, isPlaying, videoElements, getDesiredVideoStates]); - // FIXED: Properly stop animation when not playing useEffect(() => { if (!isPlaying) { if (animationRef.current) { @@ -466,7 +477,6 @@ const VideoEditor = ({ width, height }) => { }; }, [isPlaying, totalDuration, handlePause, updateVideoTimes]); - // FIXED: Stabilized handlers const handlePlay = useCallback(() => { if (!isPlaying) { setIsPlaying(true); @@ -504,7 +514,6 @@ const VideoEditor = ({ width, height }) => { const activeElements = getActiveElements(currentTime); - // FIXED: Added missing dependencies to event listeners useEffect(() => { emitter.on('video-play', handlePlay); emitter.on('video-reset', handleReset); @@ -540,6 +549,7 @@ const VideoEditor = ({ width, height }) => { handleSeek={handleSeek} copyFFmpegCommand={copyFFmpegCommand} exportVideo={exportVideo} + onElementUpdate={handleElementUpdate} // NEW: Pass the update handler layerRef={layerRef} /> diff --git a/resources/js/modules/editor/partials/canvas/video-preview.jsx b/resources/js/modules/editor/partials/canvas/video-preview.jsx index 3246743..4df01f9 100644 --- a/resources/js/modules/editor/partials/canvas/video-preview.jsx +++ b/resources/js/modules/editor/partials/canvas/video-preview.jsx @@ -1,8 +1,5 @@ -// Use minimal react-konva core to avoid Node.js dependencies -import 'konva/lib/Animation'; -import 'konva/lib/shapes/Image'; -import 'konva/lib/shapes/Text'; -import { Image, Layer, Stage, Text } from 'react-konva/lib/ReactKonvaCore'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Image, Layer, Line, Stage, Text, Transformer } from 'react-konva'; const VideoPreview = ({ // Dimensions @@ -34,53 +31,401 @@ const VideoPreview = ({ handleSeek, copyFFmpegCommand, exportVideo, + onElementUpdate, // New prop for updating element properties // Refs layerRef, }) => { + // 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) => { - // Check if this video should be playing right now const isVideoActive = videoStates[element.id] && isPlaying; - // Use video if it's ready and currently active, otherwise use poster if (isVideoActive && element.videoElement && element.isVideoReady) { return element.videoElement; } else if (element.posterImage && element.isVideoPoster) { return element.posterImage; } - // Fallback - no image ready yet 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); + // Clear guide lines when selecting + setGuideLines({ + vertical: null, + horizontal: null, + showVertical: false, + showHorizontal: false, + }); + }, []); + + // 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; // Don't render if no source is ready + return null; } return ( { + if (node) { + elementRefs.current[element.id] = node; + } + }} image={imageSource} - x={element.x} - y={element.y} + // 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} @@ -88,26 +433,137 @@ const VideoPreview = ({ fill={element.fill} stroke={element.stroke} strokeWidth={element.strokeWidth} + 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 + 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} - x={element.x} - y={element.y} + // 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); + } + }} + /> + + {/* Selection info panel with snapping info */} + {selectedElementId && ( +
+
Selected: {selectedElementId}
+
+
• Drag to move • Corner handles to resize • Rotate handle to rotate
+
• Hold Shift while scaling to maintain aspect ratio
+
• Native Konva rotation snapping (0°, 90°, 180°, 270°)
+
• Automatic center alignment with guide lines
+
+
+ )}
); };