Browse Source

feat: move group controls into three-dot menu next to title

Consolidate group controls (color picker, lock/unlock, delete) from the
floating top-right div into a three-dot menu within the zoom-scaled title
container. Controls now scale with zoom like the title label, and are
toggled via a vertical dots button next to the group name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 4 months ago
parent
commit
58b4b20012
  1. 277
      src/components/GroupsOverlay.tsx

277
src/components/GroupsOverlay.tsx

@ -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(-12px, 0px) 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(-12px, 0px) 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>

Loading…
Cancel
Save