@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user