diff --git a/src/components/video-editor/CropControl.tsx b/src/components/video-editor/CropControl.tsx index 078b6e5..07e769d 100644 --- a/src/components/video-editor/CropControl.tsx +++ b/src/components/video-editor/CropControl.tsx @@ -16,7 +16,7 @@ interface CropControlProps { aspectRatio: AspectRatio; } -type DragHandle = "top" | "right" | "bottom" | "left" | null; +type DragHandle = "top" | "right" | "bottom" | "left" | "move" | null; export function CropControl({ videoElement, cropRegion, onCropChange }: CropControlProps) { const canvasRef = useRef(null); @@ -99,6 +99,11 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont case "right": newCrop.width = Math.max(0.1, Math.min(initialCrop.width + deltaX, 1 - initialCrop.x)); break; + case "move": { + newCrop.x = Math.max(0, Math.min(initialCrop.x + deltaX, 1 - initialCrop.width)); + newCrop.y = Math.max(0, Math.min(initialCrop.y + deltaY, 1 - initialCrop.height)); + break; + } } onCropChange(newCrop); @@ -178,6 +183,18 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont +
handlePointerDown(e, "move")} + /> +
(GRADIENTS[0]); const [showCropModal, setShowCropModal] = useState(false); const cropSnapshotRef = useRef(null); + const [cropAspectLocked, setCropAspectLocked] = useState(false); + const [cropAspectRatio, setCropAspectRatio] = useState(""); + + const videoWidth = videoElement?.videoWidth || 1920; + const videoHeight = videoElement?.videoHeight || 1080; + + const handleCropNumericChange = useCallback( + (field: "x" | "y" | "width" | "height", pixelValue: number) => { + if (!cropRegion || !onCropChange) return; + + const next = { ...cropRegion }; + switch (field) { + case "x": + next.x = Math.max(0, Math.min(pixelValue / videoWidth, 1 - next.width)); + break; + case "y": + next.y = Math.max(0, Math.min(pixelValue / videoHeight, 1 - next.height)); + break; + case "width": { + const newWidth = Math.max(0.05, Math.min(pixelValue / videoWidth, 1 - next.x)); + if (cropAspectLocked && next.width > 0 && next.height > 0) { + const ratio = next.width / next.height; + const newHeight = newWidth / ratio; + if (next.y + newHeight <= 1) { + next.width = newWidth; + next.height = newHeight; + } + } else { + next.width = newWidth; + } + break; + } + case "height": { + const newHeight = Math.max(0.05, Math.min(pixelValue / videoHeight, 1 - next.y)); + if (cropAspectLocked && next.width > 0 && next.height > 0) { + const ratio = next.width / next.height; + const newWidth = newHeight * ratio; + if (next.x + newWidth <= 1) { + next.height = newHeight; + next.width = newWidth; + } + } else { + next.height = newHeight; + } + break; + } + } + + onCropChange(next); + }, + [cropRegion, onCropChange, videoWidth, videoHeight, cropAspectLocked], + ); + + const applyCropAspectPreset = useCallback( + (preset: string) => { + if (!cropRegion || !onCropChange) return; + + setCropAspectRatio(preset); + if (preset === "") { + setCropAspectLocked(false); + return; + } + + const [wStr, hStr] = preset.split(":"); + const targetRatio = Number(wStr) / Number(hStr); + const next = { ...cropRegion }; + + const nextHeight = (next.width * videoWidth) / (targetRatio * videoHeight); + if (next.y + nextHeight <= 1 && nextHeight >= 0.05) { + next.height = nextHeight; + } else { + const nextWidth = (next.height * videoHeight * targetRatio) / videoWidth; + if (next.x + nextWidth <= 1 && nextWidth >= 0.05) { + next.width = nextWidth; + } + } + + onCropChange(next); + setCropAspectLocked(true); + }, + [cropRegion, onCropChange, videoWidth, videoHeight], + ); + + const getCropPixelValue = useCallback( + (field: "x" | "y" | "width" | "height"): number => { + if (!cropRegion) return 0; + switch (field) { + case "x": + return Math.round(cropRegion.x * videoWidth); + case "y": + return Math.round(cropRegion.y * videoHeight); + case "width": + return Math.round(cropRegion.width * videoWidth); + case "height": + return Math.round(cropRegion.height * videoHeight); + } + }, + [cropRegion, videoWidth, videoHeight], + ); const zoomEnabled = Boolean(selectedZoomDepth); const trimEnabled = Boolean(selectedTrimId); @@ -747,14 +848,95 @@ export function SettingsPanel({ onCropChange={onCropChange} aspectRatio={aspectRatio} /> -
- +
+
+ {[ + { label: "X", field: "x" as const, max: videoWidth }, + { label: "Y", field: "y" as const, max: videoHeight }, + { label: "W", field: "width" as const, max: videoWidth }, + { label: "H", field: "height" as const, max: videoHeight }, + ].map(({ label, field, max }) => ( +
+ + handleCropNumericChange(field, Number(e.target.value))} + className="w-[90px] h-8 rounded-md border border-white/10 bg-white/5 px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 focus:ring-1 focus:ring-[#34B27B]/30 [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" + /> +
+ ))} + +
+ +
+ + +
+
+ +

+ {videoWidth} × {videoHeight}px +

+
+ +
+ +