Revert "Update"

This reverts commit bf5e875ee9.
This commit is contained in:
ct
2025-06-17 20:32:49 +08:00
parent 0b0d1db35c
commit f3e46530c3
33 changed files with 89 additions and 1131 deletions

View File

@@ -57,12 +57,10 @@ const sampleTimelineElements = [
startTime: 0,
layer: 2,
duration: 4,
x: 90,
y: 180,
fontSize: 40,
fontFamily: 'Montserrat',
fontWeight: 'bold',
fontStyle: 'normal',
x: 50,
y: 600,
fontSize: 24,
fontWeight: 'bold', // ADD THIS LINE
fill: 'white',
stroke: 'black',
strokeWidth: 1,
@@ -78,9 +76,7 @@ const sampleTimelineElements = [
x: 50,
y: 650,
fontSize: 20,
fontFamily: 'Montserrat',
fontWeight: 'bold',
fontStyle: 'normal',
fontWeight: 'bold', // ADD THIS LINE
fill: 'yellow',
stroke: 'red',
strokeWidth: 2,

View File

@@ -70,7 +70,7 @@ const VideoEditor = ({ width, height }) => {
});
};
// Handle element transformations (position, scale, rotation)
// NEW: Handle element transformations (position, scale, rotation)
const handleElementUpdate = useCallback(
(elementId, updates) => {
setTimelineElements((prev) =>
@@ -553,7 +553,7 @@ const VideoEditor = ({ width, height }) => {
handleSeek={handleSeek}
copyFFmpegCommand={copyFFmpegCommand}
exportVideo={exportVideo}
onElementUpdate={handleElementUpdate}
onElementUpdate={handleElementUpdate} // NEW: Pass the update handler
layerRef={layerRef}
/>
</div>

View File

@@ -98,29 +98,16 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
showConsoleLogs && console.log('🎵 Audio args:', audioArgs);
// Process text elements with font family support
// Process text elements with centering
texts.forEach((t, i) => {
const escapedText = t.text.replace(/'/g, is_string ? "\\'" : "'").replace(/:/g, '\\:');
// Determine font file based on weight and style
let fontFile = 'Montserrat-Regular.ttf'; // default
const isBold = t.fontWeight === 'bold' || t.fontWeight === 700;
const isItalic = t.fontStyle === 'italic';
if (isBold && isItalic) {
fontFile = 'Montserrat-BoldItalic.ttf';
} else if (isBold) {
fontFile = 'Montserrat-Bold.ttf';
} else if (isItalic) {
fontFile = 'Montserrat-Italic.ttf';
}
// Center the text: x position is the center point, y is adjusted for baseline
const centerX = Math.round(t.x);
const centerY = Math.round(t.y + t.fontSize * 0.3); // Adjust for text baseline
filters.push(
`[${videoLayer}]drawtext=fontfile=/${fontFile}:text='${escapedText}':x=${centerX}:y=${centerY}:fontsize=${t.fontSize}:fontcolor=${t.fill}:borderw=${t.strokeWidth}:bordercolor=${
`[${videoLayer}]drawtext=fontfile=/arial.ttf:text='${escapedText}':x=${centerX}:y=${centerY}:fontsize=${t.fontSize}:fontcolor=${t.fill}:borderw=${t.strokeWidth}:bordercolor=${
t.stroke
}:text_align=center:enable='between(t,${t.startTime},${t.startTime + t.duration})'[t${i}]`,
);
@@ -224,25 +211,9 @@ const useVideoExport = ({ timelineElements, dimensions, totalDuration }) => {
showConsoleLogs && console.log('FFmpeg loaded!');
setExportProgress(20);
setExportStatus('Loading fonts...');
// Load Montserrat font variants
await ffmpeg.writeFile(
'Montserrat-Regular.ttf',
await fetchFile('https://fonts.gstatic.com/s/montserrat/v26/JTUSjIg1_i6t8kCHKm459Wlhyw.ttf'),
);
await ffmpeg.writeFile(
'Montserrat-Bold.ttf',
await fetchFile('https://fonts.gstatic.com/s/montserrat/v26/JTUSjIg1_i6t8kCHKm459W1hyw.ttf'),
);
await ffmpeg.writeFile(
'Montserrat-Italic.ttf',
await fetchFile('https://fonts.gstatic.com/s/montserrat/v26/JTUSjIg1_i6t8kCHKm459WxhywMDPA.ttf'),
);
await ffmpeg.writeFile(
'Montserrat-BoldItalic.ttf',
await fetchFile('https://fonts.gstatic.com/s/montserrat/v26/JTUSjIg1_i6t8kCHKm459W1hywMDPA.ttf'),
);
showConsoleLogs && console.log('Fonts loaded!');
setExportStatus('Loading font...');
await ffmpeg.writeFile('arial.ttf', await fetchFile('https://raw.githubusercontent.com/ffmpegwasm/testdata/master/arial.ttf'));
showConsoleLogs && console.log('Font loaded!');
setExportProgress(30);
setExportStatus('Downloading media...');

View File

@@ -58,10 +58,6 @@ const VideoPreview = ({
// Snap settings
const POSITION_SNAP_THRESHOLD = 10; // Pixels within which to snap to center
// Font size constraints (same as in text-sidebar.jsx)
const MIN_FONT_SIZE = 8;
const MAX_FONT_SIZE = 120;
// Function to determine which image source to use for videos
const getImageSource = (element) => {
const isVideoActive = videoStates[element.id] && isPlaying;
@@ -280,7 +276,7 @@ const VideoPreview = ({
[onElementUpdate, timelineElements],
);
// Handle transform events (scale, rotate) with fontSize conversion for text
// Handle transform events (scale, rotate) with snapping - USES NATIVE KONVA ROTATION SNAPPING
const handleTransform = useCallback(
(elementId) => {
const node = elementRefs.current[elementId];
@@ -295,29 +291,11 @@ const VideoPreview = ({
const scaleY = node.scaleY();
let newWidth, newHeight;
let updates = {};
if (element.type === 'text') {
// OPTION A: Convert scale change to fontSize change
const scale = Math.max(Math.abs(scaleX), Math.abs(scaleY));
const newFontSize = Math.round(element.fontSize * scale);
// Clamp fontSize to valid range
const clampedFontSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, newFontSize));
// Reset scale to 1 since we're converting to fontSize
node.scaleX(1);
node.scaleY(1);
// ✅ FIX: Always get current width/height for text elements too
newWidth = node.width();
newHeight = node.height();
updates.fontSize = clampedFontSize;
updates.width = newWidth; // ✅ Always include width
updates.height = newHeight; // ✅ Always include height
console.log(`Text transform: scale=${scale.toFixed(2)}, oldFontSize=${element.fontSize}, newFontSize=${clampedFontSize}`);
// For text, allow free scaling
newWidth = node.width() * scaleX;
newHeight = node.height() * scaleY;
} else {
// For images/videos, maintain aspect ratio by using the larger scale
const scale = Math.max(Math.abs(scaleX), Math.abs(scaleY));
@@ -333,9 +311,6 @@ const VideoPreview = ({
// Update offset for center rotation
node.offsetX(newWidth / 2);
node.offsetY(newHeight / 2);
updates.width = newWidth;
updates.height = newHeight;
}
// Calculate position for snapping
@@ -345,8 +320,8 @@ const VideoPreview = ({
// Convert center position to top-left for snapping
const centerX = node.x();
const centerY = node.y();
topLeftX = centerX - newWidth / 2; // ✅ newWidth is now always defined
topLeftY = centerY - newHeight / 2; // ✅ newHeight is now always defined
topLeftX = centerX - newWidth / 2;
topLeftY = centerY - newHeight / 2;
} else {
// Use position directly for text
topLeftX = node.x();
@@ -384,23 +359,18 @@ const VideoPreview = ({
});
}
// Always update position and rotation
updates.x = topLeftX;
updates.y = topLeftY;
updates.rotation = rotation;
// Update state with the final calculated values
const finalTransform = {
x: topLeftX,
y: topLeftY,
width: newWidth,
height: newHeight,
rotation: rotation,
};
// Update state with the calculated values
onElementUpdate(elementId, updates);
// If this is a text element and fontSize changed, emit update for sidebar (without opening it)
if (element.type === 'text' && updates.fontSize && updates.fontSize !== element.fontSize) {
// Small delay to ensure state is updated first
setTimeout(() => {
emitter.emit('text-element-updated', { ...element, ...updates });
}, 50);
}
onElementUpdate(elementId, finalTransform);
},
[onElementUpdate, dimensions.width, dimensions.height, timelineElements, emitter],
[onElementUpdate, dimensions.width, dimensions.height, timelineElements],
);
// Update transformer when selection changes
@@ -463,10 +433,6 @@ const VideoPreview = ({
/>
);
} else if (element.type === 'text') {
// Build font style string
const fontWeight = element.fontWeight === 'bold' || element.fontWeight === 700 ? 'bold' : 'normal';
const fontStyle = element.fontStyle === 'italic' ? 'italic' : 'normal';
return (
<Text
key={element.id}
@@ -479,8 +445,8 @@ const VideoPreview = ({
x={element.x}
y={element.y}
fontSize={element.fontSize}
fontStyle={`${fontStyle} ${fontWeight}`}
fontFamily={element.fontFamily || 'Montserrat'}
fontStyle={element.fontWeight === 'bold' || element.fontWeight === 700 ? 'bold' : 'normal'} // ADD THIS LINE
fontFamily="Arial"
fill={element.fill}
stroke={element.stroke}
strokeWidth={element.strokeWidth}

View File

@@ -1,614 +1,54 @@
import { useMitt } from '@/plugins/MittContext';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Image, Layer, Line, Stage, Text, Transformer } from 'react-konva';
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
// Refs
layerRef,
}) => {
const emitter = useMitt();
// Selection state
const [selectedElementId, setSelectedElementId] = useState(null);
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,
});
// Snap settings
const POSITION_SNAP_THRESHOLD = 10; // Pixels within which to snap to center
// Font size constraints (same as in text-sidebar.jsx)
const MIN_FONT_SIZE = 8;
const MAX_FONT_SIZE = 120;
// 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;
};
// 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
const handleStageClick = useCallback((e) => {
// If clicking on stage (not on an element), deselect
if (e.target === e.target.getStage()) {
setSelectedElementId(null);
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 (scale, rotate) with fontSize conversion for text
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,
updates = {};
if (element.type === 'text') {
// OPTION A: Convert scale change to fontSize change
const scale = Math.max(Math.abs(scaleX), Math.abs(scaleY));
const newFontSize = Math.round(element.fontSize * scale);
// Clamp fontSize to valid range
const clampedFontSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, newFontSize));
// Reset scale to 1 since we're converting to fontSize
node.scaleX(1);
node.scaleY(1);
// The width/height will be automatically calculated by Konva based on fontSize
// For text elements, we let Konva handle the natural dimensions
updates.fontSize = clampedFontSize;
console.log(`Text transform: scale=${scale.toFixed(2)}, oldFontSize=${element.fontSize}, newFontSize=${clampedFontSize}`);
} 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();
const currentWidth = element.type === 'text' ? node.width() : newWidth;
const currentHeight = element.type === 'text' ? node.height() : newHeight;
topLeftX = centerX - currentWidth / 2;
topLeftY = centerY - currentHeight / 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 currentWidth = element.type === 'text' ? node.width() : newWidth;
const currentHeight = element.type === 'text' ? node.height() : newHeight;
const snapResult = calculateSnapAndGuides(elementId, topLeftX, topLeftY, currentWidth, currentHeight);
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 + currentWidth / 2;
const newCenterY = snapResult.y + currentHeight / 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,
});
}
// Always update position and rotation
updates.x = topLeftX;
updates.y = topLeftY;
updates.rotation = rotation;
// Update state with the calculated values
onElementUpdate(elementId, updates);
// If this is a text element and fontSize changed, emit update for sidebar
if (element.type === 'text' && updates.fontSize && updates.fontSize !== element.fontSize) {
// Small delay to ensure state is updated first
setTimeout(() => {
emitter.emit('text-element-selected', { ...element, ...updates });
}, 50);
}
},
[onElementUpdate, dimensions.width, dimensions.height, timelineElements, emitter],
);
// 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]);
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import useLocalSettingsStore from '@/stores/localSettingsStore';
import { SettingsIcon } from 'lucide-react';
export default function EditNavSidebar({ isOpen, onClose }) {
const { getSetting, setSetting } = useLocalSettingsStore();
return (
<div>
<Stage width={dimensions.width} height={dimensions.height} ref={stageRef} onClick={handleStageClick} onTap={handleStageClick}>
<Layer ref={layerRef}>
{activeElements.map((element) => {
const isSelected = selectedElementId === element.id;
<Sheet open={isOpen} onOpenChange={(open) => !open && onClose()}>
<SheetContent side="left" className="w-50 overflow-y-auto">
<SheetHeader>
<SheetTitle className="flex items-center gap-3">
<div className="font-display ml-0 text-lg tracking-wide md:ml-3 md:text-xl">MEMEAIGEN</div>
</SheetTitle>
</SheetHeader>
if (element.type === 'video') {
const imageSource = getImageSource(element);
<div className="space-y-6">
<Dialog>
<DialogTrigger asChild>
<Button variant="link">
<SettingsIcon className="h-6 w-6" /> Settings
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
<DialogDescription>Change your settings here.</DialogDescription>
</DialogHeader>
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}
<div className="flex items-center space-x-2">
<Checkbox
id="genAlphaSlang"
checked={getSetting('genAlphaSlang')}
onCheckedChange={() => setSetting('genAlphaSlang', !getSetting('genAlphaSlang'))}
/>
);
} else if (element.type === 'text') {
// Build font style string
const fontWeight = element.fontWeight === 'bold' || element.fontWeight === 700 ? 'bold' : 'normal';
const fontStyle = element.fontStyle === 'italic' ? 'italic' : 'normal';
<label
htmlFor="genAlphaSlang"
className="text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Use gen alpha slang
</label>
</div>
return (
<Text
key={element.id}
ref={(node) => {
if (node) {
elementRefs.current[element.id] = node;
}
}}
text={element.text}
x={element.x}
y={element.y}
fontSize={element.fontSize}
fontStyle={`${fontStyle} ${fontWeight}`}
fontFamily={element.fontFamily || 'Montserrat'}
fill={element.fill}
stroke={element.stroke}
strokeWidth={element.strokeWidth}
rotation={element.rotation || 0}
// Center the text horizontally
align="center"
// Let text have natural width and height for multiline support
wrap="word"
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
shadowColor={isSelected ? '#0066ff' : undefined}
shadowBlur={isSelected ? 4 : 0}
shadowOpacity={isSelected ? 0.3 : 0}
/>
);
} 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={() => {
setGuideLines({
vertical: null,
horizontal: null,
showVertical: false,
showHorizontal: false,
});
}}
// 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>
<DialogFooter></DialogFooter>
</DialogContent>
</Dialog>
</div>
</SheetContent>
</Sheet>
);
};
export default VideoPreview;
}

View File

@@ -1,72 +1,32 @@
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
import { Textarea } from '@/components/ui/textarea';
import { useMitt } from '@/plugins/MittContext';
import useVideoEditorStore from '@/stores/VideoEditorStore';
import { Bold, Italic, Minus, Plus, Type } from 'lucide-react';
import { Bold, Minus, Plus, Type } from 'lucide-react';
import { useEffect, useState } from 'react';
// Font configuration
const DEFAULT_FONT_FAMILY = 'Montserrat';
const AVAILABLE_FONTS = [{ value: 'Montserrat', label: 'Montserrat' }];
export default function TextSidebar({ isOpen, onClose }) {
const { selectedTextElement } = useVideoEditorStore();
const emitter = useMitt();
const [textValue, setTextValue] = useState('');
const [fontSize, setFontSize] = useState(24);
const [fontFamily, setFontFamily] = useState(DEFAULT_FONT_FAMILY);
const [isBold, setIsBold] = useState(true);
const [isItalic, setIsItalic] = useState(false);
const [fontSize, setFontSize] = useState(24); // Default font size
const [isBold, setIsBold] = useState(true); // Default to bold
// Font size constraints
const MIN_FONT_SIZE = 8;
const MAX_FONT_SIZE = 120;
const FONT_SIZE_STEP = 2;
// Update all state when selected element changes
// Update textarea, fontSize, and bold when selected element changes
useEffect(() => {
if (selectedTextElement) {
setTextValue(selectedTextElement.text || '');
setFontSize(selectedTextElement.fontSize || 24);
setFontFamily(selectedTextElement.fontFamily || DEFAULT_FONT_FAMILY);
setIsBold(selectedTextElement.fontWeight === 'bold' || selectedTextElement.fontWeight === 700 || true);
setIsItalic(selectedTextElement.fontStyle === 'italic' || false);
setIsBold(selectedTextElement.fontWeight === 'bold' || selectedTextElement.fontWeight === 700 || true); // Default to bold if not set
}
}, [selectedTextElement]);
// Listen for fontSize changes from canvas transformations (separate from selection)
useEffect(() => {
const handleTextElementUpdate = (updatedElement) => {
if (selectedTextElement && updatedElement.id === selectedTextElement.id) {
// Update local state to reflect changes from canvas
if (updatedElement.fontSize !== undefined) {
setFontSize(updatedElement.fontSize);
}
if (updatedElement.text !== undefined) {
setTextValue(updatedElement.text);
}
if (updatedElement.fontFamily !== undefined) {
setFontFamily(updatedElement.fontFamily);
}
if (updatedElement.fontWeight !== undefined) {
setIsBold(updatedElement.fontWeight === 'bold' || updatedElement.fontWeight === 700);
}
if (updatedElement.fontStyle !== undefined) {
setIsItalic(updatedElement.fontStyle === 'italic');
}
}
};
// Listen for updates from canvas transforms (doesn't open sidebar)
emitter.on('text-element-updated', handleTextElementUpdate);
return () => {
emitter.off('text-element-updated', handleTextElementUpdate);
};
}, [emitter, selectedTextElement]);
// Handle text changes
const handleTextChange = (e) => {
const newText = e.target.value;
@@ -80,18 +40,6 @@ export default function TextSidebar({ isOpen, onClose }) {
}
};
// Handle font family changes
const handleFontFamilyChange = (newFontFamily) => {
setFontFamily(newFontFamily);
if (selectedTextElement) {
emitter.emit('text-update', {
elementId: selectedTextElement.id,
updates: { fontFamily: newFontFamily },
});
}
};
// Handle font size changes
const handleFontSizeChange = (newSize) => {
const clampedSize = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, newSize));
@@ -118,19 +66,6 @@ export default function TextSidebar({ isOpen, onClose }) {
}
};
// Handle italic toggle
const handleItalicToggle = () => {
const newItalicState = !isItalic;
setIsItalic(newItalicState);
if (selectedTextElement) {
emitter.emit('text-update', {
elementId: selectedTextElement.id,
updates: { fontStyle: newItalicState ? 'italic' : 'normal' },
});
}
};
// Increase font size
const increaseFontSize = () => {
handleFontSizeChange(fontSize + FONT_SIZE_STEP);
@@ -161,46 +96,11 @@ export default function TextSidebar({ isOpen, onClose }) {
value={textValue}
onChange={handleTextChange}
placeholder="Enter your text..."
className="mt-2 border-2 text-center text-nowrap"
className="mt-2 text-center"
rows={4}
style={{
fontFamily: fontFamily,
fontSize: `${fontSize * 0.45}px`, // Better scaling
fontWeight: isBold ? 'bold' : 'normal',
fontStyle: isItalic ? 'italic' : 'normal',
lineHeight: '1.4',
}}
/>
</div>
{/* Font Fa
{/* Font Family */}
<div>
<label className="text-sm font-medium">Font Family</label>
<Select value={fontFamily} onValueChange={handleFontFamilyChange}>
<SelectTrigger className="mt-2">
<SelectValue placeholder="Select font">
<span style={{ fontFamily: fontFamily }}>
{AVAILABLE_FONTS.find((f) => f.value === fontFamily)?.label || 'Select font'}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent>
{AVAILABLE_FONTS.map((font) => (
<SelectItem
key={font.value}
value={font.value}
style={{ fontFamily: font.value }}
className="cursor-pointer"
>
<span style={{ fontFamily: font.value }}>{font.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Font Size Controls */}
<div>
<label className="text-sm font-medium">Font Size</label>
@@ -235,35 +135,20 @@ export default function TextSidebar({ isOpen, onClose }) {
<div className="mt-1 text-center text-xs text-gray-500">
Size range: {MIN_FONT_SIZE}px - {MAX_FONT_SIZE}px
</div>
{/* Visual feedback for canvas scaling */}
<div className="mt-1 text-center text-xs text-blue-600">
💡 Tip: You can also resize text by dragging the corners on the canvas
</div>
</div>
{/* Font Style Controls */}
<div>
<label className="text-sm font-medium">Font Style</label>
<div className="mt-2 flex gap-2">
<div className="mt-2">
<Button
variant={isBold ? 'default' : 'outline'}
size="sm"
onClick={handleBoldToggle}
className="flex flex-1 items-center gap-2"
className="flex w-full items-center gap-2"
>
<Bold className="h-4 w-4" />
<span className={isBold ? 'font-bold' : 'font-normal'}>Bold</span>
</Button>
<Button
variant={isItalic ? 'default' : 'outline'}
size="sm"
onClick={handleItalicToggle}
className="flex flex-1 items-center gap-2"
>
<Italic className="h-4 w-4" />
<span className={isItalic ? 'italic' : 'not-italic'}>Italic</span>
<span className={isBold ? 'font-bold' : 'font-normal'}>{isBold ? 'Bold' : 'Normal'}</span>
</Button>
</div>
</div>