Merge PR #184: resolve crop control conflicts
This commit is contained in:
@@ -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);
|
||||
@@ -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
|
||||
</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]")}
|
||||
style={{
|
||||
|
||||
@@ -6,15 +6,17 @@ import {
|
||||
Film,
|
||||
FolderOpen,
|
||||
Image,
|
||||
Lock,
|
||||
Palette,
|
||||
Save,
|
||||
Sparkles,
|
||||
Star,
|
||||
Trash2,
|
||||
Unlock,
|
||||
Upload,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Accordion,
|
||||
@@ -234,6 +236,105 @@ export function SettingsPanel({
|
||||
const [gradient, setGradient] = useState<string>(GRADIENTS[0]);
|
||||
const [showCropModal, setShowCropModal] = useState(false);
|
||||
const cropSnapshotRef = useRef<CropRegion | null>(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}
|
||||
/>
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
onClick={() => setShowCropModal(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={getCropPixelValue(field)}
|
||||
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
|
||||
type="button"
|
||||
onClick={() => setCropAspectLocked((prev) => !prev)}
|
||||
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={() => setShowCropModal(false)}
|
||||
size="lg"
|
||||
className="bg-[#34B27B] hover:bg-[#34B27B]/90 text-white"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user