mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
236 lines
5.9 KiB
TypeScript
236 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
import { useRef, useState, type PointerEvent } from "react";
|
|
import { useShiftKey } from "@/hooks/use-shift-key";
|
|
import { getBezierPoint } from "@/animation/bezier";
|
|
import type { NormalizedCubicBezier } from "@/animation/types";
|
|
import { cn } from "@/utils/ui";
|
|
|
|
const GRAPH_WIDTH = 140;
|
|
const GRAPH_HEIGHT = 94;
|
|
const GRAPH_PADDING = 12;
|
|
const SVG_WIDTH = GRAPH_WIDTH + GRAPH_PADDING * 2;
|
|
const SVG_HEIGHT = GRAPH_HEIGHT + GRAPH_PADDING * 2;
|
|
const HANDLE_RADIUS = 3.5;
|
|
const ENDPOINT_RADIUS = 2;
|
|
const SNAP_THRESHOLD = 0.06;
|
|
const SNAP_TARGETS = [0, 1];
|
|
const CURVE_SEGMENTS = 64;
|
|
const Y_CLAMP_MIN = -0.5;
|
|
const Y_CLAMP_MAX = 1.5;
|
|
|
|
type BezierHandle = "c1" | "c2";
|
|
|
|
export const BEZIER_GRAPH_MIN_HEIGHT = SVG_HEIGHT;
|
|
|
|
function snap({
|
|
value,
|
|
targets,
|
|
isEnabled,
|
|
}: {
|
|
value: number;
|
|
targets: number[];
|
|
isEnabled: boolean;
|
|
}) {
|
|
if (!isEnabled) return value;
|
|
for (const target of targets) {
|
|
if (Math.abs(value - target) < SNAP_THRESHOLD) return target;
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function toSvgX({ value }: { value: number }) {
|
|
return GRAPH_PADDING + value * GRAPH_WIDTH;
|
|
}
|
|
|
|
function toSvgY({ value }: { value: number }) {
|
|
return GRAPH_PADDING + (1 - value) * GRAPH_HEIGHT;
|
|
}
|
|
|
|
function fromSvgX({ svgX }: { svgX: number }) {
|
|
return Math.max(0, Math.min(1, (svgX - GRAPH_PADDING) / GRAPH_WIDTH));
|
|
}
|
|
|
|
function fromSvgY({ svgY }: { svgY: number }) {
|
|
return Math.max(
|
|
Y_CLAMP_MIN,
|
|
Math.min(Y_CLAMP_MAX, 1 - (svgY - GRAPH_PADDING) / GRAPH_HEIGHT),
|
|
);
|
|
}
|
|
|
|
function curvePath({ curve }: { curve: NormalizedCubicBezier }) {
|
|
const points: string[] = [];
|
|
for (let i = 0; i <= CURVE_SEGMENTS; i++) {
|
|
const progress = i / CURVE_SEGMENTS;
|
|
const x = toSvgX({ value: getBezierPoint({ progress, p0: 0, p1: curve[0], p2: curve[2], p3: 1 }) });
|
|
const y = toSvgY({ value: getBezierPoint({ progress, p0: 0, p1: curve[1], p2: curve[3], p3: 1 }) });
|
|
points.push(`${x},${y}`);
|
|
}
|
|
return `M${points.join("L")}`;
|
|
}
|
|
|
|
function clampHandleY({ svgY }: { svgY: number }) {
|
|
return Math.max(HANDLE_RADIUS, Math.min(SVG_HEIGHT - HANDLE_RADIUS, svgY));
|
|
}
|
|
|
|
export function BezierGraph({
|
|
value,
|
|
onChange,
|
|
onChangeEnd,
|
|
onCancel,
|
|
}: {
|
|
value: NormalizedCubicBezier;
|
|
onChange?: (value: NormalizedCubicBezier) => void;
|
|
onChangeEnd?: (value: NormalizedCubicBezier) => void;
|
|
onCancel?: () => void;
|
|
}) {
|
|
const svgRef = useRef<SVGSVGElement>(null);
|
|
const [activeHandle, setActiveHandle] = useState<BezierHandle | null>(null);
|
|
const isShiftPressedRef = useShiftKey();
|
|
const latestValueRef = useRef(value);
|
|
|
|
latestValueRef.current = value;
|
|
|
|
function getPointerPosition({
|
|
event,
|
|
}: {
|
|
event: PointerEvent;
|
|
}): { x: number; y: number } {
|
|
const svg = svgRef.current;
|
|
if (!svg) return { x: 0, y: 0 };
|
|
const rect = svg.getBoundingClientRect();
|
|
const scale = SVG_WIDTH / rect.width;
|
|
return {
|
|
x: (event.clientX - rect.left) * scale,
|
|
y: (event.clientY - rect.top) * (SVG_HEIGHT / rect.height),
|
|
};
|
|
}
|
|
|
|
function onHandlePointerDown({ handle }: { handle: BezierHandle }) {
|
|
return (event: PointerEvent<SVGCircleElement>) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setActiveHandle(handle);
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
};
|
|
}
|
|
|
|
function onPointerMove({ event }: { event: PointerEvent<SVGSVGElement> }) {
|
|
if (!activeHandle) return;
|
|
const pointerPos = getPointerPosition({ event });
|
|
const x = fromSvgX({ svgX: pointerPos.x });
|
|
const y = snap({
|
|
value: fromSvgY({ svgY: pointerPos.y }),
|
|
targets: SNAP_TARGETS,
|
|
isEnabled: !isShiftPressedRef.current,
|
|
});
|
|
const next: NormalizedCubicBezier = [...value];
|
|
if (activeHandle === "c1") {
|
|
next[0] = x;
|
|
next[1] = y;
|
|
} else {
|
|
next[2] = x;
|
|
next[3] = y;
|
|
}
|
|
latestValueRef.current = next;
|
|
onChange?.(next);
|
|
}
|
|
|
|
function onPointerUp() {
|
|
if (!activeHandle) return;
|
|
setActiveHandle(null);
|
|
onChangeEnd?.(latestValueRef.current);
|
|
}
|
|
|
|
function onPointerCancel() {
|
|
if (!activeHandle) return;
|
|
setActiveHandle(null);
|
|
onCancel?.();
|
|
}
|
|
|
|
const path = curvePath({ curve: value });
|
|
const c1 = { x: toSvgX({ value: value[0] }), y: toSvgY({ value: value[1] }) };
|
|
const c2 = { x: toSvgX({ value: value[2] }), y: toSvgY({ value: value[3] }) };
|
|
const c1Clamped = { x: c1.x, y: clampHandleY({ svgY: c1.y }) };
|
|
const c2Clamped = { x: c2.x, y: clampHandleY({ svgY: c2.y }) };
|
|
const p0 = { x: toSvgX({ value: 0 }), y: toSvgY({ value: 0 }) };
|
|
const p1 = { x: toSvgX({ value: 1 }), y: toSvgY({ value: 1 }) };
|
|
|
|
return (
|
|
<svg
|
|
ref={svgRef}
|
|
viewBox={`0 0 ${SVG_WIDTH} ${SVG_HEIGHT}`}
|
|
className="bg-foreground/3 w-full cursor-crosshair select-none"
|
|
onPointerMove={(event) => onPointerMove({ event })}
|
|
onPointerUp={onPointerUp}
|
|
onPointerCancel={onPointerCancel}
|
|
>
|
|
<title>Bezier curve editor</title>
|
|
<line
|
|
x1={p0.x}
|
|
y1={p0.y}
|
|
x2={p1.x}
|
|
y2={p1.y}
|
|
className="stroke-foreground/8"
|
|
strokeWidth={1}
|
|
strokeDasharray="3 3"
|
|
/>
|
|
<line
|
|
x1={p0.x}
|
|
y1={p0.y}
|
|
x2={c1Clamped.x}
|
|
y2={c1Clamped.y}
|
|
className="stroke-primary/30"
|
|
strokeWidth={1}
|
|
/>
|
|
<line
|
|
x1={p1.x}
|
|
y1={p1.y}
|
|
x2={c2Clamped.x}
|
|
y2={c2Clamped.y}
|
|
className="stroke-primary/30"
|
|
strokeWidth={1}
|
|
/>
|
|
<path
|
|
d={path}
|
|
fill="none"
|
|
className="stroke-primary"
|
|
strokeWidth={2}
|
|
strokeLinecap="round"
|
|
/>
|
|
<circle
|
|
cx={p0.x}
|
|
cy={p0.y}
|
|
r={ENDPOINT_RADIUS}
|
|
className="fill-foreground/20"
|
|
/>
|
|
<circle
|
|
cx={p1.x}
|
|
cy={p1.y}
|
|
r={ENDPOINT_RADIUS}
|
|
className="fill-foreground/20"
|
|
/>
|
|
<circle
|
|
cx={c1Clamped.x}
|
|
cy={c1Clamped.y}
|
|
r={HANDLE_RADIUS}
|
|
className={cn(
|
|
"fill-primary cursor-grab",
|
|
activeHandle === "c1" && "cursor-grabbing",
|
|
)}
|
|
onPointerDown={onHandlePointerDown({ handle: "c1" })}
|
|
/>
|
|
<circle
|
|
cx={c2Clamped.x}
|
|
cy={c2Clamped.y}
|
|
r={HANDLE_RADIUS}
|
|
className={cn(
|
|
"fill-primary cursor-grab",
|
|
activeHandle === "c2" && "cursor-grabbing",
|
|
)}
|
|
onPointerDown={onHandlePointerDown({ handle: "c2" })}
|
|
/>
|
|
</svg>
|
|
);
|
|
}
|