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 <noreply@anthropic.com>
This commit is contained in:
Hemkesh
2026-03-04 21:37:17 -06:00
parent 9eb362012b
commit 7226632fc4
2 changed files with 163 additions and 11 deletions
+20 -1
View File
@@ -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<HTMLCanvasElement>(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
</svg>
</div>
<div
className="absolute z-10 pointer-events-auto cursor-move"
style={{
left: `${cropPixelX}%`,
top: `${cropPixelY}%`,
width: `${cropPixelWidth}%`,
height: `${cropPixelHeight}%`,
transition: 'none',
}}
onPointerDown={(e) => handlePointerDown(e, 'move')}
/>
<div
className={cn(
"absolute h-[3px] cursor-ns-resize z-20 pointer-events-auto bg-[#34B27B]"
+143 -10
View File
@@ -5,9 +5,9 @@ import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { useState } from "react";
import { useState, useCallback } from "react";
import Block from '@uiw/react-color-block';
import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette, Save, FolderOpen } from "lucide-react";
import { Trash2, Download, Crop, X, Bug, Upload, Star, Film, Image, Sparkles, Palette, Save, FolderOpen, Lock, Unlock } from "lucide-react";
import { toast } from "sonner";
import type { ZoomDepth, CropRegion, AnnotationRegion, AnnotationType, PlaybackSpeed } from "./types";
import { SPEED_OPTIONS } from "./types";
@@ -184,6 +184,81 @@ export function SettingsPanel({
const [selectedColor, setSelectedColor] = useState('#ADADAD');
const [gradient, setGradient] = useState<string>(GRADIENTS[0]);
const [showCropDropdown, setShowCropDropdown] = useState(false);
const [cropAspectLocked, setCropAspectLocked] = useState(false);
const [cropAspectRatio, setCropAspectRatio] = useState<string>('');
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}
/>
<div className="mt-6 flex justify-end">
<Button
onClick={() => setShowCropDropdown(false)}
size="lg"
className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
>
Done
</Button>
<div className="mt-6 space-y-4">
<div className="flex flex-wrap items-end gap-3">
{[
{ 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 }) => (
<div key={field} className="flex flex-col gap-1">
<label className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">{label}</label>
<input
type="number"
min={0}
max={max}
value={Math.round((cropRegion as Record<string, number>)[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"
/>
</div>
))}
<div className="flex flex-col gap-1">
<label className="text-[10px] font-medium text-slate-400 uppercase tracking-wider">Ratio</label>
<div className="flex items-center gap-1.5">
<select
value={cropAspectRatio}
onChange={(e) => applyCropAspectPreset(e.target.value)}
className="h-8 rounded-md border border-white/10 bg-[#1a1a1f] px-2 text-xs text-slate-200 outline-none focus:border-[#34B27B]/50 cursor-pointer"
>
<option value="" className="bg-[#1a1a1f] text-slate-200">Free</option>
<option value="16:9" className="bg-[#1a1a1f] text-slate-200">16:9</option>
<option value="9:16" className="bg-[#1a1a1f] text-slate-200">9:16</option>
<option value="4:3" className="bg-[#1a1a1f] text-slate-200">4:3</option>
<option value="3:4" className="bg-[#1a1a1f] text-slate-200">3:4</option>
<option value="1:1" className="bg-[#1a1a1f] text-slate-200">1:1</option>
<option value="21:9" className="bg-[#1a1a1f] text-slate-200">21:9</option>
</select>
<button
onClick={() => setCropAspectLocked(!cropAspectLocked)}
className={cn(
"h-8 w-8 flex items-center justify-center rounded-md border transition-all",
cropAspectLocked
? "border-[#34B27B]/50 bg-[#34B27B]/10 text-[#34B27B]"
: "border-white/10 bg-white/5 text-slate-400 hover:text-slate-200"
)}
title={cropAspectLocked ? "Unlock aspect ratio" : "Lock aspect ratio"}
>
{cropAspectLocked ? <Lock className="w-3.5 h-3.5" /> : <Unlock className="w-3.5 h-3.5" />}
</button>
</div>
</div>
<p className="text-[10px] text-slate-500 self-center ml-2">
{videoWidth} × {videoHeight}px
</p>
</div>
<div className="flex justify-end">
<Button
onClick={() => setShowCropDropdown(false)}
size="lg"
className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
>
Done
</Button>
</div>
</div>
</div>
</>