Update
This commit is contained in:
@@ -14,6 +14,7 @@ const sampleTimelineElements = [
|
|||||||
y: 50,
|
y: 50,
|
||||||
width: 300,
|
width: 300,
|
||||||
height: 200,
|
height: 200,
|
||||||
|
rotation: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '2',
|
id: '2',
|
||||||
@@ -30,6 +31,7 @@ const sampleTimelineElements = [
|
|||||||
y: 100,
|
y: 100,
|
||||||
width: 250,
|
width: 250,
|
||||||
height: 150,
|
height: 150,
|
||||||
|
rotation: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '3',
|
id: '3',
|
||||||
@@ -46,20 +48,22 @@ const sampleTimelineElements = [
|
|||||||
y: 200,
|
y: 200,
|
||||||
width: 280,
|
width: 280,
|
||||||
height: 180,
|
height: 180,
|
||||||
|
rotation: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '4',
|
id: '4',
|
||||||
type: 'text',
|
type: 'text',
|
||||||
text: 'Welcome to the Timeline!',
|
text: 'Welcome to the Timeline!',
|
||||||
startTime: 1,
|
startTime: 0,
|
||||||
layer: 2,
|
layer: 2,
|
||||||
duration: 3,
|
duration: 4,
|
||||||
x: 50,
|
x: 50,
|
||||||
y: 600,
|
y: 600,
|
||||||
fontSize: 24,
|
fontSize: 24,
|
||||||
fill: 'white',
|
fill: 'white',
|
||||||
stroke: 'black',
|
stroke: 'black',
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
|
rotation: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '5',
|
id: '5',
|
||||||
@@ -74,6 +78,7 @@ const sampleTimelineElements = [
|
|||||||
fill: 'yellow',
|
fill: 'yellow',
|
||||||
stroke: 'red',
|
stroke: 'red',
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
|
rotation: 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '6',
|
id: '6',
|
||||||
@@ -88,6 +93,7 @@ const sampleTimelineElements = [
|
|||||||
y: 200,
|
y: 200,
|
||||||
width: 280,
|
width: 280,
|
||||||
height: 180,
|
height: 180,
|
||||||
|
rotation: 0,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useMitt } from '@/plugins/MittContext';
|
||||||
import useVideoEditorStore from '@/stores/VideoEditorStore';
|
import useVideoEditorStore from '@/stores/VideoEditorStore';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
@@ -16,8 +14,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [timelineElements, setTimelineElements] = useState([]);
|
const [timelineElements, setTimelineElements] = useState([]);
|
||||||
|
|
||||||
// 🔧 FIX: Add ref to solve closure issue
|
|
||||||
const timelineElementsRef = useRef([]);
|
const timelineElementsRef = useRef([]);
|
||||||
|
|
||||||
const lastUpdateRef = useRef(0);
|
const lastUpdateRef = useRef(0);
|
||||||
@@ -39,12 +35,12 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
|
|
||||||
const FPS_INTERVAL = 1000 / 30; // 30 FPS
|
const FPS_INTERVAL = 1000 / 30; // 30 FPS
|
||||||
|
|
||||||
// 🔧 FIX: Keep ref synced with state
|
// Keep ref synced with state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
timelineElementsRef.current = timelineElements;
|
timelineElementsRef.current = timelineElements;
|
||||||
}, [timelineElements]);
|
}, [timelineElements]);
|
||||||
|
|
||||||
// ✅ FIX 1: Use useEffect to automatically setup videos when timeline loads
|
// Initialize timeline
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
initTimeline();
|
initTimeline();
|
||||||
}, []);
|
}, []);
|
||||||
@@ -58,7 +54,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Add this useEffect to resolve the promise when timeline updates
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (timelineUpdateResolverRef.current && timelineElements.length > 0) {
|
if (timelineUpdateResolverRef.current && timelineElements.length > 0) {
|
||||||
timelineUpdateResolverRef.current();
|
timelineUpdateResolverRef.current();
|
||||||
@@ -70,13 +65,35 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
cleanupVideos(videoElements);
|
cleanupVideos(videoElements);
|
||||||
setTimelineElementsAsync(sampleTimelineElements).then(() => {
|
setTimelineElementsAsync(sampleTimelineElements).then(() => {
|
||||||
showConsoleLogs && console.log('Loaded sample timeline');
|
showConsoleLogs && console.log('Loaded sample timeline');
|
||||||
|
|
||||||
setupVideos();
|
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 = () => {
|
const setupImages = () => {
|
||||||
showConsoleLogs && console.log('setupImages');
|
showConsoleLogs && console.log('setupImages');
|
||||||
|
|
||||||
@@ -108,7 +125,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
let scaledWidth = imgWidth;
|
let scaledWidth = imgWidth;
|
||||||
let scaledHeight = imgHeight;
|
let scaledHeight = imgHeight;
|
||||||
|
|
||||||
// Scale down if image is larger than canvas
|
|
||||||
if (imgWidth > maxWidth || imgHeight > maxHeight) {
|
if (imgWidth > maxWidth || imgHeight > maxHeight) {
|
||||||
const scaleX = maxWidth / imgWidth;
|
const scaleX = maxWidth / imgWidth;
|
||||||
const scaleY = maxHeight / imgHeight;
|
const scaleY = maxHeight / imgHeight;
|
||||||
@@ -118,7 +134,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
scaledHeight = imgHeight * scale;
|
scaledHeight = imgHeight * scale;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use provided position or center the image
|
|
||||||
const centeredX = element.x || (maxWidth - scaledWidth) / 2;
|
const centeredX = element.x || (maxWidth - scaledWidth) / 2;
|
||||||
const centeredY = element.y || (maxHeight - scaledHeight) / 2;
|
const centeredY = element.y || (maxHeight - scaledHeight) / 2;
|
||||||
|
|
||||||
@@ -131,6 +146,7 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
y: centeredY,
|
y: centeredY,
|
||||||
width: element.width || scaledWidth,
|
width: element.width || scaledWidth,
|
||||||
height: element.height || scaledHeight,
|
height: element.height || scaledHeight,
|
||||||
|
rotation: element.rotation || 0,
|
||||||
imageElement: img,
|
imageElement: img,
|
||||||
isImageReady: true,
|
isImageReady: true,
|
||||||
};
|
};
|
||||||
@@ -153,7 +169,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ✅ FIX 3: Auto-update status when videos load
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setupVideoStatus();
|
setupVideoStatus();
|
||||||
}, [timelineElements, loadedVideos]);
|
}, [timelineElements, loadedVideos]);
|
||||||
@@ -164,7 +179,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
|
|
||||||
const totalDuration = Math.max(...timelineElements.map((el) => el.startTime + el.duration));
|
const totalDuration = Math.max(...timelineElements.map((el) => el.startTime + el.duration));
|
||||||
|
|
||||||
// Use the FFmpeg hook
|
|
||||||
const { isExporting, exportProgress, exportStatus, ffmpegCommand, copyFFmpegCommand, exportVideo } = useVideoExport({
|
const { isExporting, exportProgress, exportStatus, ffmpegCommand, copyFFmpegCommand, exportVideo } = useVideoExport({
|
||||||
timelineElements,
|
timelineElements,
|
||||||
dimensions,
|
dimensions,
|
||||||
@@ -174,7 +188,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
const setupVideos = () => {
|
const setupVideos = () => {
|
||||||
showConsoleLogs && console.log('setupVideos');
|
showConsoleLogs && console.log('setupVideos');
|
||||||
|
|
||||||
// 🔧 FIX: Read from ref instead of state to get latest data
|
|
||||||
const elements = timelineElementsRef.current;
|
const elements = timelineElementsRef.current;
|
||||||
|
|
||||||
if (elements.length === 0) {
|
if (elements.length === 0) {
|
||||||
@@ -246,6 +259,7 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
y: centeredY,
|
y: centeredY,
|
||||||
width: scaledWidth,
|
width: scaledWidth,
|
||||||
height: scaledHeight,
|
height: scaledHeight,
|
||||||
|
rotation: element.rotation || 0,
|
||||||
posterImage: posterImg,
|
posterImage: posterImg,
|
||||||
isVideoPoster: true,
|
isVideoPoster: true,
|
||||||
};
|
};
|
||||||
@@ -311,7 +325,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const setupVideoStatus = () => {
|
const setupVideoStatus = () => {
|
||||||
// Update to count both videos and images
|
|
||||||
const mediaCount = timelineElements.filter((el) => el.type === 'video' || el.type === 'image').length;
|
const mediaCount = timelineElements.filter((el) => el.type === 'video' || el.type === 'image').length;
|
||||||
if (loadedVideos.size === mediaCount && mediaCount > 0) {
|
if (loadedVideos.size === mediaCount && mediaCount > 0) {
|
||||||
setStatus('Ready to play');
|
setStatus('Ready to play');
|
||||||
@@ -322,7 +335,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// FIXED: Removed currentTime dependency to prevent excessive recreation
|
|
||||||
const handlePause = useCallback(() => {
|
const handlePause = useCallback(() => {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
setIsPlaying(false);
|
setIsPlaying(false);
|
||||||
@@ -412,7 +424,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
setVideoStates(desiredStates);
|
setVideoStates(desiredStates);
|
||||||
}, [currentTime, isPlaying, videoElements, getDesiredVideoStates]);
|
}, [currentTime, isPlaying, videoElements, getDesiredVideoStates]);
|
||||||
|
|
||||||
// FIXED: Properly stop animation when not playing
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPlaying) {
|
if (!isPlaying) {
|
||||||
if (animationRef.current) {
|
if (animationRef.current) {
|
||||||
@@ -466,7 +477,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
};
|
};
|
||||||
}, [isPlaying, totalDuration, handlePause, updateVideoTimes]);
|
}, [isPlaying, totalDuration, handlePause, updateVideoTimes]);
|
||||||
|
|
||||||
// FIXED: Stabilized handlers
|
|
||||||
const handlePlay = useCallback(() => {
|
const handlePlay = useCallback(() => {
|
||||||
if (!isPlaying) {
|
if (!isPlaying) {
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
@@ -504,7 +514,6 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
|
|
||||||
const activeElements = getActiveElements(currentTime);
|
const activeElements = getActiveElements(currentTime);
|
||||||
|
|
||||||
// FIXED: Added missing dependencies to event listeners
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
emitter.on('video-play', handlePlay);
|
emitter.on('video-play', handlePlay);
|
||||||
emitter.on('video-reset', handleReset);
|
emitter.on('video-reset', handleReset);
|
||||||
@@ -540,6 +549,7 @@ const VideoEditor = ({ width, height }) => {
|
|||||||
handleSeek={handleSeek}
|
handleSeek={handleSeek}
|
||||||
copyFFmpegCommand={copyFFmpegCommand}
|
copyFFmpegCommand={copyFFmpegCommand}
|
||||||
exportVideo={exportVideo}
|
exportVideo={exportVideo}
|
||||||
|
onElementUpdate={handleElementUpdate} // NEW: Pass the update handler
|
||||||
layerRef={layerRef}
|
layerRef={layerRef}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
// Use minimal react-konva core to avoid Node.js dependencies
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import 'konva/lib/Animation';
|
import { Image, Layer, Line, Stage, Text, Transformer } from 'react-konva';
|
||||||
import 'konva/lib/shapes/Image';
|
|
||||||
import 'konva/lib/shapes/Text';
|
|
||||||
import { Image, Layer, Stage, Text } from 'react-konva/lib/ReactKonvaCore';
|
|
||||||
|
|
||||||
const VideoPreview = ({
|
const VideoPreview = ({
|
||||||
// Dimensions
|
// Dimensions
|
||||||
@@ -34,53 +31,401 @@ const VideoPreview = ({
|
|||||||
handleSeek,
|
handleSeek,
|
||||||
copyFFmpegCommand,
|
copyFFmpegCommand,
|
||||||
exportVideo,
|
exportVideo,
|
||||||
|
onElementUpdate, // New prop for updating element properties
|
||||||
|
|
||||||
// Refs
|
// Refs
|
||||||
layerRef,
|
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
|
// Function to determine which image source to use for videos
|
||||||
const getImageSource = (element) => {
|
const getImageSource = (element) => {
|
||||||
// Check if this video should be playing right now
|
|
||||||
const isVideoActive = videoStates[element.id] && isPlaying;
|
const isVideoActive = videoStates[element.id] && isPlaying;
|
||||||
|
|
||||||
// Use video if it's ready and currently active, otherwise use poster
|
|
||||||
if (isVideoActive && element.videoElement && element.isVideoReady) {
|
if (isVideoActive && element.videoElement && element.isVideoReady) {
|
||||||
return element.videoElement;
|
return element.videoElement;
|
||||||
} else if (element.posterImage && element.isVideoPoster) {
|
} else if (element.posterImage && element.isVideoPoster) {
|
||||||
return element.posterImage;
|
return element.posterImage;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback - no image ready yet
|
|
||||||
return null;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Stage width={dimensions.width} height={dimensions.height} className="">
|
<Stage width={dimensions.width} height={dimensions.height} ref={stageRef} onClick={handleStageClick} onTap={handleStageClick}>
|
||||||
<Layer ref={layerRef}>
|
<Layer ref={layerRef}>
|
||||||
{activeElements.map((element) => {
|
{activeElements.map((element) => {
|
||||||
|
const isSelected = selectedElementId === element.id;
|
||||||
|
|
||||||
if (element.type === 'video') {
|
if (element.type === 'video') {
|
||||||
const imageSource = getImageSource(element);
|
const imageSource = getImageSource(element);
|
||||||
|
|
||||||
if (!imageSource) {
|
if (!imageSource) {
|
||||||
return null; // Don't render if no source is ready
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
key={element.id}
|
key={element.id}
|
||||||
|
ref={(node) => {
|
||||||
|
if (node) {
|
||||||
|
elementRefs.current[element.id] = node;
|
||||||
|
}
|
||||||
|
}}
|
||||||
image={imageSource}
|
image={imageSource}
|
||||||
x={element.x}
|
// Use center position for x,y when offset is set
|
||||||
y={element.y}
|
x={element.x + element.width / 2}
|
||||||
|
y={element.y + element.height / 2}
|
||||||
width={element.width}
|
width={element.width}
|
||||||
height={element.height}
|
height={element.height}
|
||||||
|
// Set offset to center for proper rotation
|
||||||
|
offsetX={element.width / 2}
|
||||||
|
offsetY={element.height / 2}
|
||||||
|
rotation={element.rotation || 0}
|
||||||
draggable
|
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') {
|
} else if (element.type === 'text') {
|
||||||
return (
|
return (
|
||||||
<Text
|
<Text
|
||||||
key={element.id}
|
key={element.id}
|
||||||
|
ref={(node) => {
|
||||||
|
if (node) {
|
||||||
|
elementRefs.current[element.id] = node;
|
||||||
|
}
|
||||||
|
}}
|
||||||
text={element.text}
|
text={element.text}
|
||||||
x={element.x}
|
x={element.x}
|
||||||
y={element.y}
|
y={element.y}
|
||||||
@@ -88,26 +433,137 @@ const VideoPreview = ({
|
|||||||
fill={element.fill}
|
fill={element.fill}
|
||||||
stroke={element.stroke}
|
stroke={element.stroke}
|
||||||
strokeWidth={element.strokeWidth}
|
strokeWidth={element.strokeWidth}
|
||||||
|
rotation={element.rotation || 0}
|
||||||
draggable
|
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) {
|
} else if (element.type === 'image' && element.imageElement && element.isImageReady) {
|
||||||
return (
|
return (
|
||||||
<Image
|
<Image
|
||||||
key={element.id}
|
key={element.id}
|
||||||
|
ref={(node) => {
|
||||||
|
if (node) {
|
||||||
|
elementRefs.current[element.id] = node;
|
||||||
|
}
|
||||||
|
}}
|
||||||
image={element.imageElement}
|
image={element.imageElement}
|
||||||
x={element.x}
|
// Use center position for x,y when offset is set
|
||||||
y={element.y}
|
x={element.x + element.width / 2}
|
||||||
|
y={element.y + element.height / 2}
|
||||||
width={element.width}
|
width={element.width}
|
||||||
height={element.height}
|
height={element.height}
|
||||||
|
// Set offset to center for proper rotation
|
||||||
|
offsetX={element.width / 2}
|
||||||
|
offsetY={element.height / 2}
|
||||||
|
rotation={element.rotation || 0}
|
||||||
draggable
|
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;
|
return null;
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Guide Lines Layer */}
|
||||||
|
{guideLines.showVertical && (
|
||||||
|
<Line
|
||||||
|
points={[guideLines.vertical, 0, guideLines.vertical, dimensions.height]}
|
||||||
|
stroke="#0066ff"
|
||||||
|
strokeWidth={1}
|
||||||
|
dash={[4, 4]}
|
||||||
|
opacity={0.8}
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{guideLines.showHorizontal && (
|
||||||
|
<Line
|
||||||
|
points={[0, guideLines.horizontal, dimensions.width, guideLines.horizontal]}
|
||||||
|
stroke="#0066ff"
|
||||||
|
strokeWidth={1}
|
||||||
|
dash={[4, 4]}
|
||||||
|
opacity={0.8}
|
||||||
|
listening={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Transformer for selected element */}
|
||||||
|
<Transformer
|
||||||
|
ref={transformerRef}
|
||||||
|
boundBoxFunc={(oldBox, newBox) => {
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Layer>
|
</Layer>
|
||||||
</Stage>
|
</Stage>
|
||||||
|
|
||||||
|
{/* Selection info panel with snapping info */}
|
||||||
|
{selectedElementId && (
|
||||||
|
<div className="absolute top-4 left-4 rounded-lg bg-white p-3 text-sm shadow-lg">
|
||||||
|
<div className="font-medium text-gray-700">Selected: {selectedElementId}</div>
|
||||||
|
<div className="mt-1 space-y-1 text-xs text-gray-500">
|
||||||
|
<div>• Drag to move • Corner handles to resize • Rotate handle to rotate</div>
|
||||||
|
<div>• Hold Shift while scaling to maintain aspect ratio</div>
|
||||||
|
<div>• Native Konva rotation snapping (0°, 90°, 180°, 270°)</div>
|
||||||
|
<div>• Automatic center alignment with guide lines</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user