This commit is contained in:
ct
2025-06-18 11:50:17 +08:00
parent 16fe12500a
commit aeb8fd6000
5 changed files with 476 additions and 367 deletions

View File

@@ -1,7 +1,12 @@
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 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 = ({
// Dimensions
dimensions,
@@ -39,368 +44,33 @@ const VideoPreview = ({
}) => {
const emitter = useMitt();
// Selection state
const [selectedElementId, setSelectedElementId] = useState(null);
// Refs for elements and transformer
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,
});
// Use our custom hooks
const {
selectedElementId,
setSelectedElementId,
handleElementSelect,
handleStageClick: baseHandleStageClick,
} = useElementSelection(emitter, timelineElements);
// 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;
};
// 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],
const { guideLines, handleDragMove, handleDragEnd, createDragBoundFunc, handleTransform, clearGuideLines } = useElementTransform(
timelineElements,
dimensions,
onElementUpdate,
elementRefs,
);
// Handle clicking on empty space to deselect
const handleStageClick = useCallback((e) => {
// If clicking on stage (not on an element), deselect
// Enhanced stage click handler that also clears guide lines
const handleStageClick = (e) => {
baseHandleStageClick(e);
if (e.target === e.target.getStage()) {
setSelectedElementId(null);
setGuideLines({
vertical: null,
horizontal: null,
showVertical: false,
showHorizontal: false,
});
clearGuideLines();
}
}, []);
// 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
useEffect(() => {
@@ -424,7 +94,7 @@ const VideoPreview = ({
const isSelected = selectedElementId === element.id;
if (element.type === 'video') {
const imageSource = getImageSource(element);
const imageSource = getImageSource(element, videoStates, isPlaying);
if (!imageSource) {
return null;
@@ -484,6 +154,7 @@ const VideoPreview = ({
rotation={element.rotation || 0}
// Center the text horizontally
align="center"
verticalAlign="middle"
// Let text have natural width and height for multiline support
wrap="word"
// Always scale 1 - size changes go through fontSize
@@ -496,7 +167,9 @@ const VideoPreview = ({
onDragMove={(e) => handleDragMove(element.id, e)}
onDragEnd={(e) => handleDragEnd(element.id, e)}
onTransform={() => handleTransform(element.id)}
width={600}
// Apply fixedWidth and offsetX if they exist
width={element.fixedWidth}
offsetX={element.offsetX}
// Visual feedback for selection
/>
);
@@ -583,14 +256,7 @@ const VideoPreview = ({
rotationSnaps={[0, 90, 180, 270]}
rotationSnapTolerance={8}
// Clear guide lines when transform ends
onTransformEnd={() => {
setGuideLines({
vertical: null,
horizontal: null,
showVertical: false,
showHorizontal: false,
});
}}
onTransformEnd={clearGuideLines}
// Style the rotation anchor to be circular
anchorStyleFunc={(anchor) => {
if (anchor.hasName('.rotater')) {

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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,
},
};
};

View File

@@ -47,8 +47,8 @@
"startTime": 0,
"layer": 3,
"duration": 6,
"x": 90,
"y": 180,
"x": 0,
"y": 200,
"fontSize": 40,
"fontWeight": "bold",
"fontFamily": "Montserrat",
@@ -56,7 +56,9 @@
"fill": "#ffffff",
"stroke": "#000000",
"strokeWidth": 3,
"rotation": 0
"rotation": 0,
"fixedWidth": 600,
"offsetX": -60
}
]
}