From 7226632fc4e777e8c0c1ea2600eb50ec5f2d6d57 Mon Sep 17 00:00:00 2001 From: Hemkesh Date: Wed, 4 Mar 2026 21:37:17 -0600 Subject: [PATCH] Add precise crop controls with numeric inputs, aspect ratio presets, and drag-to-move - Add X, Y, W, H pixel input fields in the crop modal for exact positioning - Add aspect ratio preset dropdown (16:9, 9:16, 4:3, 3:4, 1:1, 21:9, Free) - Add lock/unlock button to maintain aspect ratio when resizing - Display source video resolution for reference - Add drag-to-move: click inside the crop area to pan it around - Fix dropdown styling for dark mode Co-Authored-By: Claude Opus 4.6 --- src/components/video-editor/CropControl.tsx | 21 ++- src/components/video-editor/SettingsPanel.tsx | 153 ++++++++++++++++-- 2 files changed, 163 insertions(+), 11 deletions(-) diff --git a/src/components/video-editor/CropControl.tsx b/src/components/video-editor/CropControl.tsx index e95b020..fc14894 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); @@ -97,6 +97,13 @@ 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': { + const newX = Math.max(0, Math.min(initialCrop.x + deltaX, 1 - initialCrop.width)); + const newY = Math.max(0, Math.min(initialCrop.y + deltaY, 1 - initialCrop.height)); + newCrop.x = newX; + newCrop.y = newY; + break; + } } onCropChange(newCrop); @@ -168,6 +175,18 @@ export function CropControl({ videoElement, cropRegion, onCropChange }: CropCont +
handlePointerDown(e, 'move')} + /> +
(GRADIENTS[0]); const [showCropDropdown, setShowCropDropdown] = useState(false); + 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 norm = { ...cropRegion }; + const maxW = videoWidth; + const maxH = videoHeight; + + switch (field) { + case 'x': + norm.x = Math.max(0, Math.min(pixelValue / maxW, 1 - norm.width)); + break; + case 'y': + norm.y = Math.max(0, Math.min(pixelValue / maxH, 1 - norm.height)); + break; + case 'width': { + const newW = Math.max(0.05, Math.min(pixelValue / maxW, 1 - norm.x)); + if (cropAspectLocked && norm.width > 0 && norm.height > 0) { + const ratio = norm.width / norm.height; + const newH = newW / ratio; + if (norm.y + newH <= 1) { + norm.width = newW; + norm.height = newH; + } + } else { + norm.width = newW; + } + break; + } + case 'height': { + const newH = Math.max(0.05, Math.min(pixelValue / maxH, 1 - norm.y)); + if (cropAspectLocked && norm.width > 0 && norm.height > 0) { + const ratio = norm.width / norm.height; + const newW = newH * ratio; + if (norm.x + newW <= 1) { + norm.height = newH; + norm.width = newW; + } + } else { + norm.height = newH; + } + break; + } + } + onCropChange(norm); + }, [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 norm = { ...cropRegion }; + // Keep the current width, adjust height to match ratio + const newH = (norm.width * videoWidth) / (targetRatio * videoHeight); + if (norm.y + newH <= 1 && newH >= 0.05) { + norm.height = newH; + } else { + // Keep height, adjust width + const newW = (norm.height * videoHeight * targetRatio) / videoWidth; + if (norm.x + newW <= 1 && newW >= 0.05) { + norm.width = newW; + } + } + onCropChange(norm); + setCropAspectLocked(true); + }, [cropRegion, onCropChange, videoWidth, videoHeight]); const zoomEnabled = Boolean(selectedZoomDepth); const trimEnabled = Boolean(selectedTrimId); @@ -624,14 +699,72 @@ 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 }) => ( +
+ + )[field] * (field === 'x' || field === 'width' ? videoWidth : videoHeight))} + onChange={(e) => 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 +

+
+ +
+ +