Skip to content

Commit 68295b2

Browse files
Merge pull request #394 from LorenzoLancia/feature/blur-selection
feat: add blur selection (rectangle, oval)
2 parents e7d5f51 + 3232918 commit 68295b2

29 files changed

Lines changed: 1456 additions & 564 deletions

package-lock.json

Lines changed: 394 additions & 494 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/video-editor/AnnotationOverlay.tsx

Lines changed: 248 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,28 @@
1-
import { useRef } from "react";
1+
import { type CSSProperties, type PointerEvent, useRef, useState } from "react";
22
import { Rnd } from "react-rnd";
33
import { cn } from "@/lib/utils";
44
import { getArrowComponent } from "./ArrowSvgs";
5-
import type { AnnotationRegion } from "./types";
5+
import {
6+
type AnnotationRegion,
7+
type BlurData,
8+
DEFAULT_BLUR_DATA,
9+
DEFAULT_BLUR_INTENSITY,
10+
} from "./types";
11+
12+
const FREEHAND_POINT_THRESHOLD = 1;
13+
14+
function buildBlurPolygonClipPath(points: Array<{ x: number; y: number }>) {
15+
if (points.length < 3) return undefined;
16+
const polygon = points.map((point) => `${point.x}% ${point.y}%`).join(", ");
17+
return `polygon(${polygon})`;
18+
}
19+
20+
function buildBlurFreehandPath(points: Array<{ x: number; y: number }>, closed = true) {
21+
if (closed ? points.length < 3 : points.length < 2) return null;
22+
const [firstPoint, ...rest] = points;
23+
const path = `M ${firstPoint.x} ${firstPoint.y} ${rest.map((point) => `L ${point.x} ${point.y}`).join(" ")}`;
24+
return closed ? `${path} Z` : path;
25+
}
626

727
interface AnnotationOverlayProps {
828
annotation: AnnotationRegion;
@@ -11,6 +31,8 @@ interface AnnotationOverlayProps {
1131
containerHeight: number;
1232
onPositionChange: (id: string, position: { x: number; y: number }) => void;
1333
onSizeChange: (id: string, size: { width: number; height: number }) => void;
34+
onBlurDataChange?: (id: string, blurData: BlurData) => void;
35+
onBlurDataCommit?: () => void;
1436
onClick: (id: string) => void;
1537
zIndex: number;
1638
isSelectedBoost: boolean; // Boost z-index when selected for easy editing
@@ -23,6 +45,8 @@ export function AnnotationOverlay({
2345
containerHeight,
2446
onPositionChange,
2547
onSizeChange,
48+
onBlurDataChange,
49+
onBlurDataCommit,
2650
onClick,
2751
zIndex,
2852
isSelectedBoost,
@@ -31,8 +55,16 @@ export function AnnotationOverlay({
3155
const y = (annotation.position.y / 100) * containerHeight;
3256
const width = (annotation.size.width / 100) * containerWidth;
3357
const height = (annotation.size.height / 100) * containerHeight;
34-
58+
const blurShape = annotation.type === "blur" ? (annotation.blurData?.shape ?? "rectangle") : null;
59+
const isSelectedFreehandBlur = isSelected && blurShape === "freehand";
3560
const isDraggingRef = useRef(false);
61+
const isDrawingFreehandRef = useRef(false);
62+
const freehandPointsRef = useRef<Array<{ x: number; y: number }>>([]);
63+
const [isFreehandDrawing, setIsFreehandDrawing] = useState(false);
64+
const [draftFreehandPoints, setDraftFreehandPoints] = useState<Array<{ x: number; y: number }>>(
65+
[],
66+
);
67+
const [livePointerPoint, setLivePointerPoint] = useState<{ x: number; y: number } | null>(null);
3668

3769
const renderArrow = () => {
3870
const direction = annotation.figureData?.arrowDirection || "right";
@@ -43,6 +75,95 @@ export function AnnotationOverlay({
4375
return <ArrowComponent color={color} strokeWidth={strokeWidth} />;
4476
};
4577

78+
const normalizePoint = (event: PointerEvent<HTMLDivElement>) => {
79+
const rect = event.currentTarget.getBoundingClientRect();
80+
const x = ((event.clientX - rect.left) / rect.width) * 100;
81+
const y = ((event.clientY - rect.top) / rect.height) * 100;
82+
return {
83+
x: Math.max(0, Math.min(100, x)),
84+
y: Math.max(0, Math.min(100, y)),
85+
};
86+
};
87+
88+
const appendFreehandPoint = (point: { x: number; y: number }) => {
89+
const points = freehandPointsRef.current;
90+
const lastPoint = points[points.length - 1];
91+
if (!lastPoint) {
92+
points.push(point);
93+
return;
94+
}
95+
const dx = point.x - lastPoint.x;
96+
const dy = point.y - lastPoint.y;
97+
// Sample freehand points in annotation-space percent units to avoid overly dense paths.
98+
if (Math.hypot(dx, dy) >= FREEHAND_POINT_THRESHOLD) {
99+
points.push(point);
100+
}
101+
};
102+
103+
const handleFreehandPointerDown = (event: PointerEvent<HTMLDivElement>) => {
104+
if (
105+
!isSelected ||
106+
annotation.type !== "blur" ||
107+
annotation.blurData?.shape !== "freehand" ||
108+
!onBlurDataChange
109+
) {
110+
return;
111+
}
112+
event.preventDefault();
113+
event.stopPropagation();
114+
event.currentTarget.setPointerCapture(event.pointerId);
115+
isDrawingFreehandRef.current = true;
116+
setIsFreehandDrawing(true);
117+
const point = normalizePoint(event);
118+
freehandPointsRef.current = [point];
119+
setDraftFreehandPoints([point]);
120+
setLivePointerPoint(point);
121+
};
122+
123+
const handleFreehandPointerMove = (event: PointerEvent<HTMLDivElement>) => {
124+
if (!isDrawingFreehandRef.current) return;
125+
event.preventDefault();
126+
event.stopPropagation();
127+
const point = normalizePoint(event);
128+
setLivePointerPoint(point);
129+
appendFreehandPoint(point);
130+
setDraftFreehandPoints([...freehandPointsRef.current]);
131+
};
132+
133+
const finishFreehandPointer = (event: PointerEvent<HTMLDivElement>) => {
134+
if (!isDrawingFreehandRef.current || !onBlurDataChange) return;
135+
isDrawingFreehandRef.current = false;
136+
setIsFreehandDrawing(false);
137+
try {
138+
event.currentTarget.releasePointerCapture(event.pointerId);
139+
} catch {
140+
// no-op if already released
141+
}
142+
const points = [...freehandPointsRef.current];
143+
if (livePointerPoint) {
144+
const last = points[points.length - 1];
145+
if (!last || Math.hypot(last.x - livePointerPoint.x, last.y - livePointerPoint.y) > 0.001) {
146+
points.push(livePointerPoint);
147+
}
148+
}
149+
if (points.length >= 3) {
150+
const closedPoints = [...points];
151+
const first = closedPoints[0];
152+
const last = closedPoints[closedPoints.length - 1];
153+
if (Math.hypot(last.x - first.x, last.y - first.y) > 0.001) {
154+
closedPoints.push({ ...first });
155+
}
156+
onBlurDataChange(annotation.id, {
157+
...(annotation.blurData || { ...DEFAULT_BLUR_DATA, shape: "freehand" }),
158+
shape: "freehand",
159+
freehandPoints: closedPoints,
160+
});
161+
setDraftFreehandPoints(closedPoints);
162+
onBlurDataCommit?.();
163+
}
164+
setLivePointerPoint(null);
165+
};
166+
46167
const renderContent = () => {
47168
switch (annotation.type) {
48169
case "text":
@@ -113,6 +234,114 @@ export function AnnotationOverlay({
113234
<div className="w-full h-full flex items-center justify-center p-2">{renderArrow()}</div>
114235
);
115236

237+
case "blur": {
238+
const shape = annotation.blurData?.shape ?? "rectangle";
239+
const blurIntensity = Math.max(
240+
1,
241+
Math.round(annotation.blurData?.intensity ?? DEFAULT_BLUR_INTENSITY),
242+
);
243+
const activeFreehandPoints =
244+
shape === "freehand"
245+
? isFreehandDrawing
246+
? draftFreehandPoints
247+
: (annotation.blurData?.freehandPoints ?? [])
248+
: [];
249+
const drawingPoints =
250+
isFreehandDrawing && livePointerPoint
251+
? (() => {
252+
const last = activeFreehandPoints[activeFreehandPoints.length - 1];
253+
if (!last) return [livePointerPoint];
254+
const dx = livePointerPoint.x - last.x;
255+
const dy = livePointerPoint.y - last.y;
256+
return Math.hypot(dx, dy) > 0.01
257+
? [...activeFreehandPoints, livePointerPoint]
258+
: activeFreehandPoints;
259+
})()
260+
: activeFreehandPoints;
261+
const clipPath =
262+
shape === "freehand" ? buildBlurPolygonClipPath(activeFreehandPoints) : undefined;
263+
const freehandPath =
264+
shape === "freehand"
265+
? buildBlurFreehandPath(
266+
isFreehandDrawing ? drawingPoints : activeFreehandPoints,
267+
!isFreehandDrawing,
268+
)
269+
: null;
270+
const currentPointerPoint = isFreehandDrawing
271+
? livePointerPoint || drawingPoints[drawingPoints.length - 1] || null
272+
: null;
273+
const shapeBorderRadius = shape === "oval" ? "50%" : shape === "rectangle" ? "8px" : "0";
274+
const shouldShowFreehandBlurFill =
275+
shape !== "freehand" || (!!clipPath && !isFreehandDrawing);
276+
const shapeMaskStyle: CSSProperties = {
277+
borderRadius: shapeBorderRadius,
278+
clipPath: isFreehandDrawing ? undefined : clipPath,
279+
WebkitClipPath: isFreehandDrawing ? undefined : clipPath,
280+
};
281+
const isFreehandSelected = isSelectedFreehandBlur;
282+
return (
283+
<div className="w-full h-full relative">
284+
<div
285+
className="absolute inset-0 overflow-hidden"
286+
style={{
287+
...shapeMaskStyle,
288+
isolation: "isolate",
289+
}}
290+
>
291+
<div
292+
className="absolute inset-0"
293+
style={{
294+
...shapeMaskStyle,
295+
backdropFilter: `blur(${blurIntensity}px)`,
296+
WebkitBackdropFilter: `blur(${blurIntensity}px)`,
297+
backgroundColor: "rgba(255, 255, 255, 0.02)",
298+
opacity: shouldShowFreehandBlurFill ? 1 : 0,
299+
}}
300+
/>
301+
{isSelected && shape !== "freehand" && (
302+
<div
303+
className="absolute inset-0 pointer-events-none border-2 border-[#34B27B]/80"
304+
style={{ borderRadius: shapeBorderRadius }}
305+
/>
306+
)}
307+
</div>
308+
{isSelected && shape === "freehand" && freehandPath && (
309+
<svg
310+
viewBox="0 0 100 100"
311+
preserveAspectRatio="none"
312+
className="absolute inset-0 pointer-events-none"
313+
>
314+
<path
315+
d={freehandPath}
316+
fill="none"
317+
stroke="#34B27B"
318+
strokeWidth="0.55"
319+
strokeLinecap="round"
320+
strokeLinejoin="round"
321+
/>
322+
{currentPointerPoint && (
323+
<circle
324+
cx={currentPointerPoint.x}
325+
cy={currentPointerPoint.y}
326+
r="0.6"
327+
fill="#34B27B"
328+
/>
329+
)}
330+
</svg>
331+
)}
332+
{isFreehandSelected && (
333+
<div
334+
className="absolute inset-0 cursor-crosshair"
335+
onPointerDown={handleFreehandPointerDown}
336+
onPointerMove={handleFreehandPointerMove}
337+
onPointerUp={finishFreehandPointer}
338+
onPointerCancel={finishFreehandPointer}
339+
/>
340+
)}
341+
</div>
342+
);
343+
}
344+
116345
default:
117346
return null;
118347
}
@@ -149,18 +378,23 @@ export function AnnotationOverlay({
149378
}}
150379
bounds="parent"
151380
className={cn(
152-
"cursor-move transition-all",
153-
isSelected && "ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
381+
"cursor-move",
382+
isSelected &&
383+
annotation.type !== "blur" &&
384+
"ring-2 ring-[#34B27B] ring-offset-2 ring-offset-transparent",
154385
)}
155386
style={{
156387
zIndex: isSelectedBoost ? zIndex + 1000 : zIndex, // Boost selected annotation to ensure it's on top
157388
pointerEvents: isSelected ? "auto" : "none",
158-
border: isSelected ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
159-
backgroundColor: isSelected ? "rgba(52, 178, 123, 0.1)" : "transparent",
160-
boxShadow: isSelected ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
389+
border:
390+
isSelected && annotation.type !== "blur" ? "2px solid rgba(52, 178, 123, 0.8)" : "none",
391+
backgroundColor:
392+
isSelected && annotation.type !== "blur" ? "rgba(52, 178, 123, 0.1)" : "transparent",
393+
boxShadow:
394+
isSelected && annotation.type !== "blur" ? "0 0 0 1px rgba(52, 178, 123, 0.35)" : "none",
161395
}}
162-
enableResizing={isSelected}
163-
disableDragging={!isSelected}
396+
enableResizing={isSelected && !isSelectedFreehandBlur}
397+
disableDragging={!isSelected || isSelectedFreehandBlur}
164398
resizeHandleStyles={{
165399
topLeft: {
166400
width: "12px",
@@ -206,11 +440,13 @@ export function AnnotationOverlay({
206440
>
207441
<div
208442
className={cn(
209-
"w-full h-full rounded-lg",
443+
"w-full h-full",
444+
annotation.type !== "blur" && "rounded-lg",
210445
annotation.type === "text" && "bg-transparent",
211446
annotation.type === "image" && "bg-transparent",
212447
annotation.type === "figure" && "bg-transparent",
213-
isSelected && "shadow-lg",
448+
annotation.type === "blur" && "bg-transparent",
449+
isSelected && annotation.type !== "blur" && "shadow-lg",
214450
)}
215451
>
216452
{renderContent()}

src/components/video-editor/AnnotationSettingsPanel.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@ import { type CustomFont, getCustomFonts } from "@/lib/customFonts";
3232
import { cn } from "@/lib/utils";
3333
import { AddCustomFontDialog } from "./AddCustomFontDialog";
3434
import { getArrowComponent } from "./ArrowSvgs";
35-
import type { AnnotationRegion, AnnotationType, ArrowDirection, FigureData } from "./types";
35+
import {
36+
type AnnotationRegion,
37+
type AnnotationType,
38+
type ArrowDirection,
39+
type FigureData,
40+
} from "./types";
3641

3742
interface AnnotationSettingsPanelProps {
3843
annotation: AnnotationRegion;

0 commit comments

Comments
 (0)