Files
memefast/resources/js/modules/editor/partials/canvas/video-preview.jsx
2025-07-07 21:17:00 +08:00

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;