Files
memefast/resources/js/modules/editor/partials/canvas/video-preview.jsx
2025-06-19 00:28:34 +08:00

311 lines
15 KiB
JavaScript

import { Button } from '@/components/ui/button';
import { useMitt } from '@/plugins/MittContext';
import useVideoEditorStore from '@/stores/VideoEditorStore';
import { Type } from 'lucide-react';
import { useEffect, useRef } from 'react';
import { Group, Image, Layer, Line, Stage, Text, Transformer } from 'react-konva';
import { Html } from 'react-konva-utils';
// 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,
// Timeline state
currentTime,
totalDuration,
isPlaying,
status,
// Export state
isExporting,
exportProgress,
exportStatus,
// Data
timelineElements,
activeElements,
videoElements,
loadedVideos,
videoStates,
ffmpegCommand,
// Event handlers
handlePlay,
handlePause,
handleReset,
handleSeek,
copyFFmpegCommand,
exportVideo,
onElementUpdate, // New prop for updating element properties
onOpenTextSidebar, // New prop for opening text sidebar
// Refs
layerRef,
}) => {
const emitter = useMitt();
const { selectedTextElement } = useVideoEditorStore();
// Refs for elements and transformer
const transformerRef = useRef(null);
const stageRef = useRef(null);
const elementRefs = useRef({});
// Use our custom hooks
const {
selectedElementId,
setSelectedElementId,
handleElementSelect,
handleStageClick: baseHandleStageClick,
} = useElementSelection(emitter, timelineElements);
const { guideLines, handleDragMove, handleDragEnd, createDragBoundFunc, handleTransform, clearGuideLines } = useElementTransform(
timelineElements,
dimensions,
onElementUpdate,
elementRefs,
);
// Enhanced stage click handler that also clears guide lines
const handleStageClick = (e) => {
baseHandleStageClick(e);
if (e.target === e.target.getStage()) {
clearGuideLines();
}
};
// 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 (
<div className="relative overflow-hidden rounded-3xl">
<Stage width={dimensions.width} height={dimensions.height} ref={stageRef} onClick={handleStageClick} onTap={handleStageClick}>
<Layer ref={layerRef}>
{activeElements.map((element) => {
const isSelected = selectedElementId === element.id;
if (element.type === 'video') {
const imageSource = getImageSource(element, videoStates, isPlaying);
if (!imageSource) {
return null;
}
return (
<Image
key={element.id}
ref={(node) => {
if (node) {
elementRefs.current[element.id] = node;
}
}}
image={imageSource}
// Use center position for x,y when offset is set
x={element.x + element.width / 2}
y={element.y + element.height / 2}
width={element.width}
height={element.height}
// Set offset to center for proper rotation
offsetX={element.width / 2}
offsetY={element.height / 2}
rotation={element.rotation || 0}
draggable
dragBoundFunc={createDragBoundFunc(element.id)}
onClick={() => handleElementSelect(element.id)}
onTap={() => handleElementSelect(element.id)}
onDragMove={(e) => handleDragMove(element.id, e)}
onDragEnd={(e) => handleDragEnd(element.id, e)}
onTransform={() => handleTransform(element.id)}
// Visual feedback for selection
stroke={isSelected ? '#0066ff' : undefined}
strokeWidth={isSelected ? 2 : 0}
strokeScaleEnabled={false}
/>
);
} else if (element.type === 'text') {
return (
<Group key={element.id}>
<Text
ref={(node) => {
if (node) {
elementRefs.current[element.id] = node;
}
}}
text={element.text}
x={element.x}
y={element.y}
fontSize={element.fontSize}
fontStyle={getTextFontStyle(element)}
fontFamily={element.fontFamily || 'Arial'}
fill={element.fill || '#ffffff'}
stroke={element.strokeWidth > 0 ? element.stroke || '#000000' : undefined}
strokeWidth={element.strokeWidth * 3 || 0}
fillAfterStrokeEnabled={true}
strokeScaleEnabled={false}
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
scaleX={1}
scaleY={1}
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)}
// Apply fixedWidth and offsetX if they exist
width={element.fixedWidth}
offsetX={element.offsetX}
// Visual feedback for selection
/>
{/* Edit button - only show when this text element is selected */}
{isSelected && (
<Html
groupProps={{
x: element.x - (element.offsetX || 0),
y: element.y - 50,
}}
divProps={{
style: {
zIndex: 10,
},
}}
>
<Button
size="icon"
className="h-12 w-12 rounded-full border shadow-sm"
onClick={() => {
handleElementSelect(element.id);
onOpenTextSidebar();
}}
>
<Type className="h-8 w-8" />
</Button>
</Html>
)}
</Group>
);
} else if (element.type === 'image' && element.imageElement && element.isImageReady) {
return (
<Image
key={element.id}
ref={(node) => {
if (node) {
elementRefs.current[element.id] = node;
}
}}
image={element.imageElement}
// Use center position for x,y when offset is set
x={element.x + element.width / 2}
y={element.y + element.height / 2}
width={element.width}
height={element.height}
// Set offset to center for proper rotation
offsetX={element.width / 2}
offsetY={element.height / 2}
rotation={element.rotation || 0}
draggable
dragBoundFunc={createDragBoundFunc(element.id)}
onClick={() => handleElementSelect(element.id)}
onTap={() => handleElementSelect(element.id)}
onDragMove={(e) => handleDragMove(element.id, e)}
onDragEnd={(e) => handleDragEnd(element.id, e)}
onTransform={() => handleTransform(element.id)}
// Visual feedback for selection
stroke={isSelected ? '#0066ff' : undefined}
strokeWidth={isSelected ? 2 : 0}
strokeScaleEnabled={false}
/>
);
}
return null;
})}
{/* Guide Lines Layer */}
{guideLines.showVertical && (
<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={clearGuideLines}
// 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>
</Stage>
</div>
);
};
export default VideoPreview;