Update
This commit is contained in:
@@ -1,7 +1,12 @@
|
|||||||
import { useMitt } from '@/plugins/MittContext';
|
import { useMitt } from '@/plugins/MittContext';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { Image, Layer, Line, Stage, Text, Transformer } from 'react-konva';
|
import { Image, Layer, Line, Stage, Text, Transformer } from 'react-konva';
|
||||||
|
|
||||||
|
// Import our custom hooks and utilities
|
||||||
|
import { useElementSelection } from './video-preview/video-preview-element-selection';
|
||||||
|
import { useElementTransform } from './video-preview/video-preview-element-transform';
|
||||||
|
import { getImageSource, getTextFontStyle } from './video-preview/video-preview-utils';
|
||||||
|
|
||||||
const VideoPreview = ({
|
const VideoPreview = ({
|
||||||
// Dimensions
|
// Dimensions
|
||||||
dimensions,
|
dimensions,
|
||||||
@@ -39,368 +44,33 @@ const VideoPreview = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const emitter = useMitt();
|
const emitter = useMitt();
|
||||||
|
|
||||||
// Selection state
|
// Refs for elements and transformer
|
||||||
const [selectedElementId, setSelectedElementId] = useState(null);
|
|
||||||
const transformerRef = useRef(null);
|
const transformerRef = useRef(null);
|
||||||
const stageRef = useRef(null);
|
const stageRef = useRef(null);
|
||||||
|
|
||||||
// Refs for each element to connect with transformer
|
|
||||||
const elementRefs = useRef({});
|
const elementRefs = useRef({});
|
||||||
|
|
||||||
// Guide lines state
|
// Use our custom hooks
|
||||||
const [guideLines, setGuideLines] = useState({
|
const {
|
||||||
vertical: null,
|
selectedElementId,
|
||||||
horizontal: null,
|
setSelectedElementId,
|
||||||
showVertical: false,
|
handleElementSelect,
|
||||||
showHorizontal: false,
|
handleStageClick: baseHandleStageClick,
|
||||||
});
|
} = useElementSelection(emitter, timelineElements);
|
||||||
|
|
||||||
// Snap settings
|
const { guideLines, handleDragMove, handleDragEnd, createDragBoundFunc, handleTransform, clearGuideLines } = useElementTransform(
|
||||||
const POSITION_SNAP_THRESHOLD = 10; // Pixels within which to snap to center
|
timelineElements,
|
||||||
|
dimensions,
|
||||||
// Function to determine which image source to use for videos
|
onElementUpdate,
|
||||||
const getImageSource = (element) => {
|
elementRefs,
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to get font style for text elements
|
|
||||||
const getTextFontStyle = (element) => {
|
|
||||||
const isBold = element.fontWeight === 'bold' || element.fontWeight === 700;
|
|
||||||
const isItalic = element.fontStyle === 'italic';
|
|
||||||
|
|
||||||
if (isBold && isItalic) {
|
|
||||||
return 'bold italic';
|
|
||||||
} else if (isBold) {
|
|
||||||
return 'bold';
|
|
||||||
} else if (isItalic) {
|
|
||||||
return 'italic';
|
|
||||||
} else {
|
|
||||||
return 'normal';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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
|
// Enhanced stage click handler that also clears guide lines
|
||||||
const handleStageClick = useCallback((e) => {
|
const handleStageClick = (e) => {
|
||||||
// If clicking on stage (not on an element), deselect
|
baseHandleStageClick(e);
|
||||||
if (e.target === e.target.getStage()) {
|
if (e.target === e.target.getStage()) {
|
||||||
setSelectedElementId(null);
|
clearGuideLines();
|
||||||
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 - TRANSFORMER IS SOURCE OF TRUTH
|
|
||||||
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;
|
|
||||||
let updates = { rotation };
|
|
||||||
|
|
||||||
if (element.type === 'text') {
|
|
||||||
// For text: Convert scale to actual fontSize and reset scale to 1
|
|
||||||
const avgScale = Math.max(Math.abs(scaleX), Math.abs(scaleY));
|
|
||||||
const newFontSize = Math.round(element.fontSize * avgScale);
|
|
||||||
|
|
||||||
// Clamp font size to reasonable limits
|
|
||||||
const clampedFontSize = Math.max(8, Math.min(120, newFontSize));
|
|
||||||
|
|
||||||
updates.fontSize = clampedFontSize;
|
|
||||||
|
|
||||||
// Reset scale to 1 since we've applied it to fontSize
|
|
||||||
node.scaleX(1);
|
|
||||||
node.scaleY(1);
|
|
||||||
|
|
||||||
// Text dimensions are handled by Konva based on content and fontSize
|
|
||||||
newWidth = node.width();
|
|
||||||
newHeight = node.height();
|
|
||||||
} 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);
|
|
||||||
|
|
||||||
updates.width = newWidth;
|
|
||||||
updates.height = newHeight;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add position to updates
|
|
||||||
updates.x = topLeftX;
|
|
||||||
updates.y = topLeftY;
|
|
||||||
|
|
||||||
// Update state with all calculated values
|
|
||||||
onElementUpdate(elementId, updates);
|
|
||||||
},
|
|
||||||
[onElementUpdate, dimensions.width, dimensions.height, timelineElements],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update transformer when selection changes
|
// Update transformer when selection changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -424,7 +94,7 @@ const VideoPreview = ({
|
|||||||
const isSelected = selectedElementId === element.id;
|
const isSelected = selectedElementId === element.id;
|
||||||
|
|
||||||
if (element.type === 'video') {
|
if (element.type === 'video') {
|
||||||
const imageSource = getImageSource(element);
|
const imageSource = getImageSource(element, videoStates, isPlaying);
|
||||||
|
|
||||||
if (!imageSource) {
|
if (!imageSource) {
|
||||||
return null;
|
return null;
|
||||||
@@ -484,6 +154,7 @@ const VideoPreview = ({
|
|||||||
rotation={element.rotation || 0}
|
rotation={element.rotation || 0}
|
||||||
// Center the text horizontally
|
// Center the text horizontally
|
||||||
align="center"
|
align="center"
|
||||||
|
verticalAlign="middle"
|
||||||
// Let text have natural width and height for multiline support
|
// Let text have natural width and height for multiline support
|
||||||
wrap="word"
|
wrap="word"
|
||||||
// Always scale 1 - size changes go through fontSize
|
// Always scale 1 - size changes go through fontSize
|
||||||
@@ -496,7 +167,9 @@ const VideoPreview = ({
|
|||||||
onDragMove={(e) => handleDragMove(element.id, e)}
|
onDragMove={(e) => handleDragMove(element.id, e)}
|
||||||
onDragEnd={(e) => handleDragEnd(element.id, e)}
|
onDragEnd={(e) => handleDragEnd(element.id, e)}
|
||||||
onTransform={() => handleTransform(element.id)}
|
onTransform={() => handleTransform(element.id)}
|
||||||
width={600}
|
// Apply fixedWidth and offsetX if they exist
|
||||||
|
width={element.fixedWidth}
|
||||||
|
offsetX={element.offsetX}
|
||||||
// Visual feedback for selection
|
// Visual feedback for selection
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -583,14 +256,7 @@ const VideoPreview = ({
|
|||||||
rotationSnaps={[0, 90, 180, 270]}
|
rotationSnaps={[0, 90, 180, 270]}
|
||||||
rotationSnapTolerance={8}
|
rotationSnapTolerance={8}
|
||||||
// Clear guide lines when transform ends
|
// Clear guide lines when transform ends
|
||||||
onTransformEnd={() => {
|
onTransformEnd={clearGuideLines}
|
||||||
setGuideLines({
|
|
||||||
vertical: null,
|
|
||||||
horizontal: null,
|
|
||||||
showVertical: false,
|
|
||||||
showHorizontal: false,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
// Style the rotation anchor to be circular
|
// Style the rotation anchor to be circular
|
||||||
anchorStyleFunc={(anchor) => {
|
anchorStyleFunc={(anchor) => {
|
||||||
if (anchor.hasName('.rotater')) {
|
if (anchor.hasName('.rotater')) {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// video-preview-element-selection.js
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
export const useElementSelection = (emitter, timelineElements) => {
|
||||||
|
// Selection state
|
||||||
|
const [selectedElementId, setSelectedElementId] = useState(null);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[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);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
selectedElementId,
|
||||||
|
setSelectedElementId,
|
||||||
|
handleElementSelect,
|
||||||
|
handleStageClick,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
// video-preview-element-transform.js
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { calculateSnapAndGuides, usesCenterPositioning } from './video-preview-utils';
|
||||||
|
|
||||||
|
export const useElementTransform = (timelineElements, dimensions, onElementUpdate, elementRefs) => {
|
||||||
|
// Guide lines state
|
||||||
|
const [guideLines, setGuideLines] = useState({
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Use fixedWidth if available, otherwise use scaled width
|
||||||
|
const width = element.fixedWidth || node.width() * node.scaleX();
|
||||||
|
const height = node.height() * node.scaleY();
|
||||||
|
|
||||||
|
let positionX = node.x();
|
||||||
|
let positionY = node.y();
|
||||||
|
|
||||||
|
// For center-positioned elements, convert to top-left for consistent snapping
|
||||||
|
if (usesCenterPositioning(element.type)) {
|
||||||
|
positionX = node.x() - width / 2;
|
||||||
|
positionY = node.y() - height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapResult = calculateSnapAndGuides(elementId, positionX, positionY, width, height, timelineElements, dimensions);
|
||||||
|
|
||||||
|
// Update guide lines
|
||||||
|
setGuideLines(snapResult.guideLines);
|
||||||
|
|
||||||
|
// Update state during drag
|
||||||
|
if (onElementUpdate) {
|
||||||
|
onElementUpdate(elementId, {
|
||||||
|
x: snapResult.x,
|
||||||
|
y: snapResult.y,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onElementUpdate, dimensions, 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;
|
||||||
|
|
||||||
|
// Use fixedWidth if available, otherwise use scaled width
|
||||||
|
const width = element.fixedWidth || node.width() * node.scaleX();
|
||||||
|
const height = node.height() * node.scaleY();
|
||||||
|
|
||||||
|
let inputX = pos.x;
|
||||||
|
let inputY = pos.y;
|
||||||
|
|
||||||
|
// Convert center position to top-left for snapping calculations if needed
|
||||||
|
if (usesCenterPositioning(element.type)) {
|
||||||
|
inputX = pos.x - width / 2;
|
||||||
|
inputY = pos.y - height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapResult = calculateSnapAndGuides(elementId, inputX, inputY, width, height, timelineElements, dimensions);
|
||||||
|
|
||||||
|
// Convert back to the appropriate coordinate system
|
||||||
|
if (usesCenterPositioning(element.type)) {
|
||||||
|
return {
|
||||||
|
x: snapResult.x + width / 2,
|
||||||
|
y: snapResult.y + height / 2,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
x: snapResult.x,
|
||||||
|
y: snapResult.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[timelineElements, dimensions, elementRefs],
|
||||||
|
);
|
||||||
|
|
||||||
|
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 = element.fixedWidth || node.width() * node.scaleX();
|
||||||
|
const height = node.height() * node.scaleY();
|
||||||
|
|
||||||
|
let finalX = node.x();
|
||||||
|
let finalY = node.y();
|
||||||
|
|
||||||
|
// Convert center position to top-left for consistent storage
|
||||||
|
if (usesCenterPositioning(element.type)) {
|
||||||
|
finalX = node.x() - width / 2;
|
||||||
|
finalY = node.y() - height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onElementUpdate) {
|
||||||
|
onElementUpdate(elementId, {
|
||||||
|
x: finalX,
|
||||||
|
y: finalY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onElementUpdate, timelineElements],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle transform events - TRANSFORMER IS SOURCE OF TRUTH
|
||||||
|
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;
|
||||||
|
let updates = { rotation };
|
||||||
|
|
||||||
|
if (element.type === 'text') {
|
||||||
|
// For text: Convert scale to actual fontSize and reset scale to 1
|
||||||
|
const avgScale = Math.max(Math.abs(scaleX), Math.abs(scaleY));
|
||||||
|
const newFontSize = Math.round(element.fontSize * avgScale);
|
||||||
|
|
||||||
|
// Clamp font size to reasonable limits
|
||||||
|
const clampedFontSize = Math.max(8, Math.min(120, newFontSize));
|
||||||
|
|
||||||
|
updates.fontSize = clampedFontSize;
|
||||||
|
|
||||||
|
// Reset scale to 1 since we've applied it to fontSize
|
||||||
|
node.scaleX(1);
|
||||||
|
node.scaleY(1);
|
||||||
|
|
||||||
|
// Use fixedWidth if available, otherwise use natural width
|
||||||
|
newWidth = element.fixedWidth || node.width();
|
||||||
|
newHeight = node.height();
|
||||||
|
} 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);
|
||||||
|
|
||||||
|
updates.width = newWidth;
|
||||||
|
updates.height = newHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate position for snapping
|
||||||
|
let currentX = node.x();
|
||||||
|
let currentY = node.y();
|
||||||
|
|
||||||
|
// Convert center position to top-left for snapping if needed
|
||||||
|
if (usesCenterPositioning(element.type)) {
|
||||||
|
currentX = node.x() - newWidth / 2;
|
||||||
|
currentY = node.y() - newHeight / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, currentX, currentY, newWidth, newHeight, timelineElements, dimensions);
|
||||||
|
|
||||||
|
if (Math.abs(snapResult.x - currentX) > 5 || Math.abs(snapResult.y - currentY) > 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);
|
||||||
|
currentX = snapResult.x;
|
||||||
|
currentY = snapResult.y;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Clear guide lines during rotation
|
||||||
|
setGuideLines({
|
||||||
|
vertical: null,
|
||||||
|
horizontal: null,
|
||||||
|
showVertical: false,
|
||||||
|
showHorizontal: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add position to updates (always store as top-left coordinates)
|
||||||
|
updates.x = currentX;
|
||||||
|
updates.y = currentY;
|
||||||
|
|
||||||
|
// Update state with all calculated values
|
||||||
|
onElementUpdate(elementId, updates);
|
||||||
|
},
|
||||||
|
[onElementUpdate, dimensions, timelineElements, elementRefs],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear guide lines helper
|
||||||
|
const clearGuideLines = useCallback(() => {
|
||||||
|
setGuideLines({
|
||||||
|
vertical: null,
|
||||||
|
horizontal: null,
|
||||||
|
showVertical: false,
|
||||||
|
showHorizontal: false,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
guideLines,
|
||||||
|
setGuideLines,
|
||||||
|
handleDragMove,
|
||||||
|
handleDragEnd,
|
||||||
|
createDragBoundFunc,
|
||||||
|
handleTransform,
|
||||||
|
clearGuideLines,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// video-preview-utils.js
|
||||||
|
|
||||||
|
// Snap settings
|
||||||
|
export const POSITION_SNAP_THRESHOLD = 10; // Pixels within which to snap to center
|
||||||
|
|
||||||
|
// Function to determine which image source to use for videos
|
||||||
|
export const getImageSource = (element, videoStates, isPlaying) => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get font style for text elements
|
||||||
|
export const getTextFontStyle = (element) => {
|
||||||
|
const isBold = element.fontWeight === 'bold' || element.fontWeight === 700;
|
||||||
|
const isItalic = element.fontStyle === 'italic';
|
||||||
|
|
||||||
|
if (isBold && isItalic) {
|
||||||
|
return 'bold italic';
|
||||||
|
} else if (isBold) {
|
||||||
|
return 'bold';
|
||||||
|
} else if (isItalic) {
|
||||||
|
return 'italic';
|
||||||
|
} else {
|
||||||
|
return 'normal';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if element uses center-offset positioning
|
||||||
|
export const usesCenterPositioning = (elementType) => {
|
||||||
|
return elementType === 'video' || elementType === 'image';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper function to get the visual bounds of an element accounting for offsetX
|
||||||
|
export const getVisualBounds = (element, x, y, width, height) => {
|
||||||
|
// Check if element has offsetX property
|
||||||
|
if (element.offsetX !== undefined) {
|
||||||
|
// For elements with offsetX, calculate the visual position
|
||||||
|
const visualLeft = x - element.offsetX;
|
||||||
|
const elementWidth = element.fixedWidth || width;
|
||||||
|
return {
|
||||||
|
left: visualLeft,
|
||||||
|
top: y,
|
||||||
|
right: visualLeft + elementWidth,
|
||||||
|
bottom: y + height,
|
||||||
|
width: elementWidth,
|
||||||
|
height: height,
|
||||||
|
centerX: visualLeft + elementWidth / 2,
|
||||||
|
centerY: y + height / 2,
|
||||||
|
};
|
||||||
|
} else if (usesCenterPositioning(element.type)) {
|
||||||
|
// For center-positioned elements (video/image)
|
||||||
|
const left = x - width / 2;
|
||||||
|
const top = y - height / 2;
|
||||||
|
return {
|
||||||
|
left: left,
|
||||||
|
top: top,
|
||||||
|
right: left + width,
|
||||||
|
bottom: top + height,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
centerX: x,
|
||||||
|
centerY: y,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// For regular top-left positioned elements
|
||||||
|
return {
|
||||||
|
left: x,
|
||||||
|
top: y,
|
||||||
|
right: x + width,
|
||||||
|
bottom: y + height,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
centerX: x + width / 2,
|
||||||
|
centerY: y + height / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if position should snap to center and calculate guide lines
|
||||||
|
export const calculateSnapAndGuides = (elementId, newX, newY, width, height, timelineElements, dimensions) => {
|
||||||
|
const centerX = dimensions.width / 2;
|
||||||
|
const centerY = dimensions.height / 2;
|
||||||
|
|
||||||
|
const element = timelineElements.find((el) => el.id === elementId);
|
||||||
|
if (!element)
|
||||||
|
return {
|
||||||
|
x: newX,
|
||||||
|
y: newY,
|
||||||
|
guideLines: {
|
||||||
|
vertical: null,
|
||||||
|
horizontal: null,
|
||||||
|
showVertical: false,
|
||||||
|
showHorizontal: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get visual bounds accounting for offsetX and fixedWidth
|
||||||
|
const visualBounds = getVisualBounds(element, newX, newY, width, height);
|
||||||
|
|
||||||
|
let snapX = newX;
|
||||||
|
let snapY = newY;
|
||||||
|
let showVertical = false;
|
||||||
|
let showHorizontal = false;
|
||||||
|
let verticalLine = null;
|
||||||
|
let horizontalLine = null;
|
||||||
|
|
||||||
|
// Check vertical center snap using visual center
|
||||||
|
if (Math.abs(visualBounds.centerX - centerX) < POSITION_SNAP_THRESHOLD) {
|
||||||
|
if (element.offsetX !== undefined) {
|
||||||
|
// For elements with offsetX, calculate the x position that will center the visual bounds
|
||||||
|
const targetVisualLeft = centerX - visualBounds.width / 2;
|
||||||
|
snapX = targetVisualLeft + element.offsetX;
|
||||||
|
} else if (usesCenterPositioning(element.type)) {
|
||||||
|
snapX = centerX;
|
||||||
|
} else {
|
||||||
|
snapX = centerX - width / 2;
|
||||||
|
}
|
||||||
|
showVertical = true;
|
||||||
|
verticalLine = centerX;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check horizontal center snap using visual center
|
||||||
|
if (Math.abs(visualBounds.centerY - centerY) < POSITION_SNAP_THRESHOLD) {
|
||||||
|
if (usesCenterPositioning(element.type)) {
|
||||||
|
snapY = centerY;
|
||||||
|
} else {
|
||||||
|
snapY = centerY - height / 2;
|
||||||
|
}
|
||||||
|
showHorizontal = true;
|
||||||
|
horizontalLine = centerY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: snapX,
|
||||||
|
y: snapY,
|
||||||
|
guideLines: {
|
||||||
|
vertical: verticalLine,
|
||||||
|
horizontal: horizontalLine,
|
||||||
|
showVertical,
|
||||||
|
showHorizontal,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
"startTime": 0,
|
"startTime": 0,
|
||||||
"layer": 3,
|
"layer": 3,
|
||||||
"duration": 6,
|
"duration": 6,
|
||||||
"x": 90,
|
"x": 0,
|
||||||
"y": 180,
|
"y": 200,
|
||||||
"fontSize": 40,
|
"fontSize": 40,
|
||||||
"fontWeight": "bold",
|
"fontWeight": "bold",
|
||||||
"fontFamily": "Montserrat",
|
"fontFamily": "Montserrat",
|
||||||
@@ -56,7 +56,9 @@
|
|||||||
"fill": "#ffffff",
|
"fill": "#ffffff",
|
||||||
"stroke": "#000000",
|
"stroke": "#000000",
|
||||||
"strokeWidth": 3,
|
"strokeWidth": 3,
|
||||||
"rotation": 0
|
"rotation": 0,
|
||||||
|
"fixedWidth": 600,
|
||||||
|
"offsetX": -60
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user