1- import { useRef } from "react" ;
1+ import { type CSSProperties , type PointerEvent , useRef , useState } from "react" ;
22import { Rnd } from "react-rnd" ;
33import { cn } from "@/lib/utils" ;
44import { 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
727interface 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 ( ) }
0 commit comments