Files
openscreen/src/components/video-editor/CropControl.tsx
T
2025-11-29 11:38:09 -07:00

234 lines
7.4 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { type AspectRatio, formatAspectRatioForCSS } from "@/utils/aspectRatioUtils";
interface CropRegion {
x: number; // 0-1 normalized
y: number; // 0-1 normalized
width: number; // 0-1 normalized
height: number; // 0-1 normalized
}
interface CropControlProps {
videoElement: HTMLVideoElement | null;
cropRegion: CropRegion;
onCropChange: (region: CropRegion) => void;
aspectRatio: AspectRatio;
}
type DragHandle = 'top' | 'right' | 'bottom' | 'left' | null;
export function CropControl({ videoElement, cropRegion, onCropChange, aspectRatio }: CropControlProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState<DragHandle>(null);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [initialCrop, setInitialCrop] = useState<CropRegion>(cropRegion);
useEffect(() => {
if (!videoElement || !canvasRef.current) return;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) return;
canvas.width = videoElement.videoWidth || 1920;
canvas.height = videoElement.videoHeight || 1080;
const draw = () => {
if (videoElement.readyState >= 2) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(videoElement, 0, 0, canvas.width, canvas.height);
}
requestAnimationFrame(draw);
};
const rafId = requestAnimationFrame(draw);
return () => cancelAnimationFrame(rafId);
}, [videoElement]);
const getContainerRect = () => {
return containerRef.current?.getBoundingClientRect() || { width: 0, height: 0, left: 0, top: 0 };
};
const handlePointerDown = (e: React.PointerEvent, handle: DragHandle) => {
e.stopPropagation();
e.preventDefault();
setIsDragging(handle);
const rect = getContainerRect();
setDragStart({
x: (e.clientX - rect.left) / rect.width,
y: (e.clientY - rect.top) / rect.height,
});
setInitialCrop(cropRegion);
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!isDragging) return;
const rect = getContainerRect();
const currentX = (e.clientX - rect.left) / rect.width;
const currentY = (e.clientY - rect.top) / rect.height;
const deltaX = currentX - dragStart.x;
const deltaY = currentY - dragStart.y;
let newCrop = { ...initialCrop };
switch (isDragging) {
case 'top': {
const newY = Math.max(0, initialCrop.y + deltaY);
const bottom = initialCrop.y + initialCrop.height;
newCrop.y = Math.min(newY, bottom - 0.1);
newCrop.height = bottom - newCrop.y;
break;
}
case 'bottom':
newCrop.height = Math.max(0.1, Math.min(initialCrop.height + deltaY, 1 - initialCrop.y));
break;
case 'left': {
const newX = Math.max(0, initialCrop.x + deltaX);
const right = initialCrop.x + initialCrop.width;
newCrop.x = Math.min(newX, right - 0.1);
newCrop.width = right - newCrop.x;
break;
}
case 'right':
newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x));
break;
}
onCropChange(newCrop);
};
const handlePointerUp = (e: React.PointerEvent) => {
if (isDragging) {
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
}
}
setIsDragging(null);
};
const cropPixelX = cropRegion.x * 100;
const cropPixelY = cropRegion.y * 100;
const cropPixelWidth = cropRegion.width * 100;
const cropPixelHeight = cropRegion.height * 100;
const videoAspectRatio = videoElement ? videoElement.videoWidth / videoElement.videoHeight : 16/9;
const isVideoPortrait = videoAspectRatio < 1;
const maxContainerWidth = isVideoPortrait ? '40vw' : '75vw';
const maxContainerHeight = '75vh';
return (
<div className="w-full p-8">
<div
ref={containerRef}
className="relative w-full bg-black rounded-lg overflow-visible cursor-default select-none shadow-2xl"
style={{
aspectRatio: videoAspectRatio,
maxWidth: maxContainerWidth,
maxHeight: maxContainerHeight,
margin: '0 auto',
}}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<canvas
ref={canvasRef}
className="w-full h-full rounded-lg"
style={{ imageRendering: 'auto' }}
/>
<div className="absolute inset-0 pointer-events-none" style={{ transition: 'none' }}>
<svg width="100%" height="100%" className="absolute inset-0" style={{ transition: 'none' }}>
<defs>
<mask id="cropMask">
<rect width="100%" height="100%" fill="white" />
<rect
x={`${cropPixelX}%`}
y={`${cropPixelY}%`}
width={`${cropPixelWidth}%`}
height={`${cropPixelHeight}%`}
fill="black"
style={{ transition: 'none' }}
/>
</mask>
</defs>
<rect
width="100%"
height="100%"
fill="black"
fillOpacity="0.6"
mask="url(#cropMask)"
style={{ transition: 'none' }}
/>
</svg>
</div>
<div
className={cn(
"absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]"
)}
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY}%`,
width: `${cropPixelWidth}%`,
transform: 'translateY(-50%)',
willChange: 'transform',
transition: 'none',
}}
onPointerDown={(e) => handlePointerDown(e, 'top')}
/>
<div
className={cn(
"absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]"
)}
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY + cropPixelHeight}%`,
width: `${cropPixelWidth}%`,
transform: 'translateY(-50%)',
willChange: 'transform',
transition: 'none',
}}
onPointerDown={(e) => handlePointerDown(e, 'bottom')}
/>
<div
className={cn(
"absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#34B27B]"
)}
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY}%`,
height: `${cropPixelHeight}%`,
transform: 'translateX(-50%)',
willChange: 'transform',
transition: 'none',
}}
onPointerDown={(e) => handlePointerDown(e, 'left')}
/>
<div
className={cn(
"absolute w-[3px] cursor-ew-resize z-20 pointer-events-auto bg-[#34B27B]"
)}
style={{
left: `${cropPixelX + cropPixelWidth}%`,
top: `${cropPixelY}%`,
height: `${cropPixelHeight}%`,
transform: 'translateX(-50%)',
willChange: 'transform',
transition: 'none',
}}
onPointerDown={(e) => handlePointerDown(e, 'right')}
/>
</div>
</div>
);
}