418 lines
20 KiB
JavaScript
418 lines
20 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, useState } 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 } from './video-preview/video-preview-utils';
|
|
|
|
// Import centralized font management
|
|
import { getFontStyle, loadTimelineFonts, WATERMARK_CONFIG } from '@/modules/editor/fonts';
|
|
|
|
const VideoPreview = ({
|
|
watermarked,
|
|
// 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({});
|
|
|
|
// Font loading state
|
|
const [fontsLoaded, setFontsLoaded] = useState(false);
|
|
const [fontLoadingAttempted, setFontLoadingAttempted] = useState(false);
|
|
|
|
// Load fonts when timeline elements change
|
|
useEffect(() => {
|
|
const loadFonts = async () => {
|
|
if (timelineElements.length > 0 && !fontLoadingAttempted) {
|
|
setFontLoadingAttempted(true);
|
|
try {
|
|
await loadTimelineFonts(timelineElements);
|
|
setFontsLoaded(true);
|
|
console.log('✅ Fonts loaded in preview');
|
|
|
|
// Force redraw after fonts load to recalculate text dimensions
|
|
setTimeout(() => {
|
|
if (layerRef.current) {
|
|
layerRef.current.batchDraw();
|
|
}
|
|
}, 100);
|
|
} catch (error) {
|
|
console.warn('⚠️ Font loading failed:', error);
|
|
setFontsLoaded(true); // Continue anyway with fallback fonts
|
|
}
|
|
}
|
|
};
|
|
|
|
loadFonts();
|
|
}, [timelineElements, fontLoadingAttempted]);
|
|
|
|
// Force text remeasurement when fonts load
|
|
useEffect(() => {
|
|
if (fontsLoaded && layerRef.current) {
|
|
// Find all text nodes and force them to recalculate
|
|
const textNodes = layerRef.current.find('Text');
|
|
textNodes.forEach((textNode) => {
|
|
// Force Konva to recalculate text dimensions
|
|
textNode._setTextData();
|
|
textNode.cache();
|
|
textNode.clearCache();
|
|
});
|
|
layerRef.current.batchDraw();
|
|
}
|
|
}, [fontsLoaded]);
|
|
|
|
// 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();
|
|
}
|
|
};
|
|
|
|
// Pre-select first text node on load
|
|
useEffect(() => {
|
|
if (activeElements.length > 0 && !selectedElementId) {
|
|
const firstTextElement = activeElements.find((element) => element.type === 'text');
|
|
if (firstTextElement) {
|
|
setSelectedElementId(firstTextElement.id);
|
|
}
|
|
}
|
|
}, [activeElements, selectedElementId, setSelectedElementId]);
|
|
|
|
// 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}-${fontsLoaded}`}>
|
|
<Text
|
|
ref={(node) => {
|
|
if (node) {
|
|
elementRefs.current[element.id] = node;
|
|
|
|
// Force text measurement after font loading
|
|
if (fontsLoaded) {
|
|
setTimeout(() => {
|
|
node._setTextData();
|
|
|
|
// Debug log preview text properties
|
|
console.log(`🔍 Preview Text ${element.id} Properties:`);
|
|
console.log(' text:', element.text);
|
|
console.log(' x:', element.x);
|
|
console.log(' y:', element.y);
|
|
console.log(' fontSize:', element.fontSize);
|
|
console.log(' fontFamily:', element.fontFamily);
|
|
console.log(' width (fixedWidth):', element.fixedWidth);
|
|
console.log(' offsetX:', element.offsetX);
|
|
console.log(' node.width():', node.width());
|
|
console.log(' node.height():', node.height());
|
|
console.log(' node.textWidth:', node.textWidth);
|
|
console.log(' node.textHeight:', node.textHeight);
|
|
|
|
if (layerRef.current) {
|
|
layerRef.current.batchDraw();
|
|
}
|
|
}, 0);
|
|
}
|
|
}
|
|
}}
|
|
text={element.text}
|
|
x={element.x}
|
|
y={element.y}
|
|
fontSize={element.fontSize}
|
|
fontStyle={getFontStyle(element)} // Use centralized function
|
|
fontFamily={element.fontFamily || 'Montserrat'}
|
|
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) - 10,
|
|
y: element.y - 70,
|
|
}}
|
|
divProps={{
|
|
style: {
|
|
zIndex: 10,
|
|
},
|
|
}}
|
|
>
|
|
<Button
|
|
variant="secondary"
|
|
id="open-text-editor"
|
|
className="h-16 w-16 rounded-full shadow-xl"
|
|
onClick={() => {
|
|
handleElementSelect(element.id);
|
|
onOpenTextSidebar();
|
|
}}
|
|
>
|
|
<Type className="size-6" />
|
|
</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;
|
|
})}
|
|
|
|
{/* Watermark - only show when watermarked is true */}
|
|
{watermarked && (
|
|
<Text
|
|
key={`watermark-${fontsLoaded}`}
|
|
text="memefa.st"
|
|
x={dimensions.width / 2}
|
|
y={dimensions.height / 2 + dimensions.height * 0.2}
|
|
fontSize={WATERMARK_CONFIG.fontSize}
|
|
fontFamily={WATERMARK_CONFIG.fontFamily}
|
|
fill={WATERMARK_CONFIG.fill}
|
|
stroke={WATERMARK_CONFIG.stroke}
|
|
strokeWidth={WATERMARK_CONFIG.strokeWidth}
|
|
opacity={WATERMARK_CONFIG.opacity}
|
|
align="center"
|
|
verticalAlign="middle"
|
|
offsetX={90} // Approximate half-width to center the text
|
|
offsetY={5} // Approximate half-height to center the text
|
|
draggable={false}
|
|
listening={false} // Prevents any mouse interactions
|
|
/>
|
|
)}
|
|
|
|
{/* 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;
|