@ -66,6 +66,7 @@ function GroupControls({ groupId, zoom }: GroupControlsProps) {
const [ isEditing , setIsEditing ] = useState ( false ) ;
const [ isEditing , setIsEditing ] = useState ( false ) ;
const [ editName , setEditName ] = useState ( group ? . name || "" ) ;
const [ editName , setEditName ] = useState ( group ? . name || "" ) ;
const [ showColorPicker , setShowColorPicker ] = useState ( false ) ;
const [ showColorPicker , setShowColorPicker ] = useState ( false ) ;
const [ showMenu , setShowMenu ] = useState ( false ) ;
const [ isDragging , setIsDragging ] = useState ( false ) ;
const [ isDragging , setIsDragging ] = useState ( false ) ;
const [ isResizing , setIsResizing ] = useState ( false ) ;
const [ isResizing , setIsResizing ] = useState ( false ) ;
const [ resizeHandle , setResizeHandle ] = useState < string | null > ( null ) ;
const [ resizeHandle , setResizeHandle ] = useState < string | null > ( null ) ;
@ -73,6 +74,7 @@ function GroupControls({ groupId, zoom }: GroupControlsProps) {
const resizeStartRef = useRef < { x : number ; y : number ; width : number ; height : number ; posX : number ; posY : number } | null > ( null ) ;
const resizeStartRef = useRef < { x : number ; y : number ; width : number ; height : number ; posX : number ; posY : number } | null > ( null ) ;
const inputRef = useRef < HTMLInputElement > ( null ) ;
const inputRef = useRef < HTMLInputElement > ( null ) ;
const colorPickerRef = useRef < HTMLDivElement > ( null ) ;
const colorPickerRef = useRef < HTMLDivElement > ( null ) ;
const menuRef = useRef < HTMLDivElement > ( null ) ;
useEffect ( ( ) = > {
useEffect ( ( ) = > {
if ( group ? . name && ! isEditing ) {
if ( group ? . name && ! isEditing ) {
@ -92,13 +94,16 @@ function GroupControls({ groupId, zoom }: GroupControlsProps) {
if ( colorPickerRef . current && ! colorPickerRef . current . contains ( e . target as Node ) ) {
if ( colorPickerRef . current && ! colorPickerRef . current . contains ( e . target as Node ) ) {
setShowColorPicker ( false ) ;
setShowColorPicker ( false ) ;
}
}
if ( menuRef . current && ! menuRef . current . contains ( e . target as Node ) ) {
setShowMenu ( false ) ;
}
} ;
} ;
if ( showColorPicker ) {
if ( showColorPicker || showMenu ) {
document . addEventListener ( "mousedown" , handleClickOutside ) ;
document . addEventListener ( "mousedown" , handleClickOutside ) ;
}
}
return ( ) = > document . removeEventListener ( "mousedown" , handleClickOutside ) ;
return ( ) = > document . removeEventListener ( "mousedown" , handleClickOutside ) ;
} , [ showColorPicker ] ) ;
} , [ showColorPicker , showMenu ] ) ;
const handleNameSubmit = useCallback ( ( ) = > {
const handleNameSubmit = useCallback ( ( ) = > {
if ( editName . trim ( ) && editName !== group ? . name ) {
if ( editName . trim ( ) && editName !== group ? . name ) {
@ -279,7 +284,7 @@ function GroupControls({ groupId, zoom }: GroupControlsProps) {
overflow : "visible" ,
overflow : "visible" ,
} }
} }
>
>
{ /* Floating group name label - top-left, viewport-scaled */ }
{ /* Group title label + three-dot menu - top-left, viewport-scaled */ }
{ /* Outer wrapper: zero-height anchor at the top edge of the group */ }
{ /* Outer wrapper: zero-height anchor at the top edge of the group */ }
< div
< div
className = "absolute left-0"
className = "absolute left-0"
@ -287,6 +292,7 @@ function GroupControls({ groupId, zoom }: GroupControlsProps) {
>
>
{ /* Inner scaled element: bottom-anchored so it grows upward, scale keeps bottom-left fixed */ }
{ /* Inner scaled element: bottom-anchored so it grows upward, scale keeps bottom-left fixed */ }
< div
< div
ref = { menuRef }
className = "absolute left-0 pointer-events-auto cursor-grab active:cursor-grabbing select-none"
className = "absolute left-0 pointer-events-auto cursor-grab active:cursor-grabbing select-none"
style = { {
style = { {
bottom : 0 ,
bottom : 0 ,
@ -297,139 +303,152 @@ function GroupControls({ groupId, zoom }: GroupControlsProps) {
onMouseDown = { handleHeaderMouseDown }
onMouseDown = { handleHeaderMouseDown }
>
>
< div
< div
className = "flex items-center rounded-md px-2 py-0.5 mb-1"
className = "flex items-center gap-0.5 mb-1"
style = { { backgroundColor : bgColor } }
>
>
{ isEditing ? (
{ /* Title pill */ }
< input
< div
ref = { inputRef }
className = "flex items-center rounded-md px-2 py-0.5"
type = "text"
value = { editName }
onChange = { ( e ) = > setEditName ( e . target . value ) }
onBlur = { handleNameSubmit }
onKeyDown = { handleKeyDown }
className = "bg-transparent border-none outline-none text-xs font-medium text-white px-0 py-0"
style = { { minWidth : 60 , maxWidth : 200 , width : ` ${ Math . max ( 60 , editName . length * 7 ) } px ` } }
/ >
) : (
< span
className = "text-xs font-medium text-white truncate"
style = { { maxWidth : 200 } }
onDoubleClick = { ( e ) = > { e . stopPropagation ( ) ; setIsEditing ( true ) ; } }
>
{ group . name }
< / span >
) }
< / div >
< / div >
< / div >
{ /* Floating controls - top-right, scales naturally with canvas zoom */ }
< div
className = "absolute right-0 pointer-events-auto"
style = { {
top : - 28 ,
} }
>
< div className = "flex items-center gap-1 px-1.5 py-0.5" >
{ /* Color Picker */ }
< div className = "relative flex items-center" ref = { colorPickerRef } >
< button
onClick = { ( ) = > setShowColorPicker ( ! showColorPicker ) }
className = "w-4 h-4 rounded border border-white/30 hover:border-white/60 transition-colors"
style = { { backgroundColor : bgColor } }
style = { { backgroundColor : bgColor } }
title = "Change color"
>
/ >
{ isEditing ? (
{ showColorPicker && (
< input
< >
ref = { inputRef }
{ /* Invisible backdrop to catch clicks outside */ }
type = "text"
< div
value = { editName }
className = "fixed inset-0 z-40"
onChange = { ( e ) = > setEditName ( e . target . value ) }
onClick = { ( ) = > setShowColorPicker ( false ) }
onBlur = { handleNameSubmit }
onKeyDown = { handleKeyDown }
className = "bg-transparent border-none outline-none text-xs font-medium text-white px-0 py-0"
style = { { minWidth : 60 , maxWidth : 200 , width : ` ${ Math . max ( 60 , editName . length * 7 ) } px ` } }
/ >
/ >
< div className = "absolute bottom-full left-1/2 mb-2 z-50 pointer-events-auto" style = { { transform : "translateX(-50%)" } } >
) : (
{ COLOR_OPTIONS . map ( ( { color , label } , index ) = > {
< span
// Fan out in an arc above the button
className = "text-xs font-medium text-white truncate"
const totalItems = COLOR_OPTIONS . length ;
style = { { maxWidth : 200 } }
const arcSpread = 180 ; // degrees of arc spread (wider)
onDoubleClick = { ( e ) = > { e . stopPropagation ( ) ; setIsEditing ( true ) ; } }
const startAngle = - 90 - arcSpread / 2 ; // start from top-left
>
const angleStep = arcSpread / ( totalItems - 1 ) ;
{ group . name }
const angle = startAngle + index * angleStep ;
< / span >
const radius = 55 ; // distance from center (larger)
) }
const rad = ( angle * Math . PI ) / 180 ;
< / div >
const x = Math . cos ( rad ) * radius ;
const y = Math . sin ( rad ) * radius ;
{ /* Expanded controls - shown when menu is open */ }
const finalX = x - 12 ;
{ showMenu && (
const finalY = y - 12 ;
< div className = "flex items-center gap-0.5" >
{ /* Color Picker */ }
return (
< div className = "relative flex items-center" ref = { colorPickerRef } >
< button
< button
key = { color }
onClick = { ( e ) = > { e . stopPropagation ( ) ; setShowColorPicker ( ! showColorPicker ) ; } }
onClick = { ( ) = > handleColorChange ( color ) }
className = "w-5 h-5 rounded-md flex items-center justify-center hover:bg-white/20 transition-colors"
className = { ` absolute w-6 h-6 rounded-full border-2 transition-[transform,border-color] duration-150 hover:scale-110 ${
title = "Change color"
group . color === color
>
? "border-white"
< div
: "border-transparent hover:border-white/50"
className = "w-3 h-3 rounded border border-white/30 hover:border-white/60 transition-colors"
} ` }
style = { { backgroundColor : bgColor } }
style = { {
/ >
backgroundColor : PICKER_PREVIEW_COLORS [ color ] ,
< / button >
transform : ` translate( ${ finalX } px, ${ finalY } px) ` ,
{ showColorPicker && (
animation : ` colorFanIn- ${ index } 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards ` ,
< >
animationDelay : ` ${ index * 0.025 } s ` ,
{ /* Invisible backdrop to catch clicks outside */ }
opacity : 0 ,
< div
// Use CSS custom properties to pass the final position to the animation
className = "fixed inset-0 z-40"
[ "--final-x" as string ] : ` ${ finalX } px ` ,
onClick = { ( ) = > setShowColorPicker ( false ) }
[ "--final-y" as string ] : ` ${ finalY } px ` ,
/ >
} }
< div className = "absolute bottom-full left-1/2 mb-2 z-50 pointer-events-auto" style = { { transform : "translateX(-50%)" } } >
title = { label }
{ COLOR_OPTIONS . map ( ( { color , label } , index ) = > {
>
const totalItems = COLOR_OPTIONS . length ;
< style > { `
const arcSpread = 180 ;
@keyframes colorFanIn - $ { index } {
const startAngle = - 90 - arcSpread / 2 ;
0 % {
const angleStep = arcSpread / ( totalItems - 1 ) ;
opacity : 0 ;
const angle = startAngle + index * angleStep ;
transform : translate ( - 12 px , 0 px ) scale ( 0.3 ) ;
const radius = 55 ;
}
const rad = ( angle * Math . PI ) / 180 ;
100 % {
const x = Math . cos ( rad ) * radius ;
opacity : 1 ;
const y = Math . sin ( rad ) * radius ;
transform : translate ( $ { finalX } px , $ { finalY } px ) scale ( 1 ) ;
const finalX = x - 12 ;
}
const finalY = y - 12 ;
}
` }</style>
return (
< / button >
< button
) ;
key = { color }
} ) }
onClick = { ( ) = > handleColorChange ( color ) }
className = { ` absolute w-6 h-6 rounded-full border-2 transition-[transform,border-color] duration-150 hover:scale-110 ${
group . color === color
? "border-white"
: "border-transparent hover:border-white/50"
} ` }
style = { {
backgroundColor : PICKER_PREVIEW_COLORS [ color ] ,
transform : ` translate( ${ finalX } px, ${ finalY } px) ` ,
animation : ` colorFanIn- ${ index } 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards ` ,
animationDelay : ` ${ index * 0.025 } s ` ,
opacity : 0 ,
[ "--final-x" as string ] : ` ${ finalX } px ` ,
[ "--final-y" as string ] : ` ${ finalY } px ` ,
} }
title = { label }
>
< style > { `
@keyframes colorFanIn - $ { index } {
0 % {
opacity : 0 ;
transform : translate ( - 12 px , 0 px ) scale ( 0.3 ) ;
}
100 % {
opacity : 1 ;
transform : translate ( $ { finalX } px , $ { finalY } px ) scale ( 1 ) ;
}
}
` }</style>
< / button >
) ;
} ) }
< / div >
< / >
) }
< / div >
< / div >
< / >
) }
< / div >
{ /* Lock/Unlock Button */ }
{ /* Lock/Unlock Button */ }
< button
< button
onClick = { handleToggleLock }
onClick = { ( e ) = > { e . stopPropagation ( ) ; handleToggleLock ( ) ; } }
className = "p-0.5 rounded hover:bg-white/20 text-white/70 hover:text-white transition-colors"
className = "w-5 h-5 rounded-md flex items-center justify-center hover:bg-white/20 text-white/70 hover:text-white transition-colors"
title = { group . locked ? "Unlock group" : "Lock group" }
title = { group . locked ? "Unlock group" : "Lock group" }
>
>
{ group . locked ? (
{ group . locked ? (
< svg className = "w-3.5 h-3.5" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 2 } >
< svg className = "w-3 h-3" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 2 } >
< path strokeLinecap = "round" strokeLinejoin = "round" d = "M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" / >
< path strokeLinecap = "round" strokeLinejoin = "round" d = "M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" / >
< / svg >
< / svg >
) : (
) : (
< svg className = "w-3.5 h-3.5" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 2 } >
< svg className = "w-3 h-3" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 2 } >
< path strokeLinecap = "round" strokeLinejoin = "round" d = "M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" / >
< path strokeLinecap = "round" strokeLinejoin = "round" d = "M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" / >
< / svg >
< / svg >
) }
< / button >
{ /* Delete Button */ }
< button
onClick = { ( e ) = > { e . stopPropagation ( ) ; handleDelete ( ) ; } }
className = "w-5 h-5 rounded-md flex items-center justify-center hover:bg-white/20 text-white/70 hover:text-white transition-colors"
title = "Delete group"
>
< svg className = "w-3 h-3" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 2 } >
< path strokeLinecap = "round" strokeLinejoin = "round" d = "M6 18L18 6M6 6l12 12" / >
< / svg >
< / button >
< / div >
) }
) }
< / button >
{ /* Delete Button */ }
{ /* Three-dot menu toggle */ }
< button
< button
onClick = { handleDelete }
onClick = { ( e ) = > { e . stopPropagation ( ) ; setShowMenu ( ! showMenu ) ; if ( showMenu ) setShowColorPicker ( false ) ; } }
className = "p-0.5 rounded hover:bg-white/20 text-white/70 hover:text-white transition-colors"
className = "w-5 h-5 rounded-md flex flex-col items-center justify-center gap-[2px] hover:bg-white/20 transition-colors"
title = "Delete group"
title = "Group options"
>
>
< svg className = "w-3.5 h-3.5" fill = "none" viewBox = "0 0 24 24" stroke = "currentColor" strokeWidth = { 2 } >
< div className = "w-[3px] h-[3px] rounded-full bg-white/70" / >
< path strokeLinecap = "round" strokeLinejoin = "round" d = "M6 18L18 6M6 6l12 12" / >
< div className = "w-[3px] h-[3px] rounded-full bg-white/70" / >
< / svg >
< div className = "w-[3px] h-[3px] rounded-full bg-white/70" / >
< / button >
< / button >
< / div >
< / div >
< / div >
< / div >
< / div >