You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

230 lines
8.7 KiB

"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { Node as FlowNode, NodeProps, NodeResizer } from "@xyflow/react";
import { GROUP_COLORS, useWorkflowStore } from "@/store/workflowStore";
import { GroupColor, GroupNodeData } from "@/types";
type GroupNodeType = FlowNode<GroupNodeData, "group">;
const COLOR_OPTIONS: { color: GroupColor; label: string }[] = [
{ color: "neutral", label: "Gray" },
{ color: "blue", label: "Blue" },
{ color: "green", label: "Green" },
{ color: "purple", label: "Purple" },
{ color: "orange", label: "Orange" },
{ color: "red", label: "Red" },
];
export function GroupNode({ data, selected }: NodeProps<GroupNodeType>) {
const groupId = data.groupId;
const group = useWorkflowStore((state) => state.groups[groupId]);
const updateGroup = useWorkflowStore((state) => state.updateGroup);
const deleteGroup = useWorkflowStore((state) => state.deleteGroup);
const toggleGroupLock = useWorkflowStore((state) => state.toggleGroupLock);
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(group?.name || "");
const [showMenu, setShowMenu] = useState(false);
const [showColorPicker, setShowColorPicker] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isEditing) setEditName(group?.name || "");
}, [group?.name, isEditing]);
useEffect(() => {
if (isEditing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [isEditing]);
useEffect(() => {
if (!showMenu && !showColorPicker) return;
const handleClickOutside = (event: MouseEvent) => {
if (menuRef.current?.contains(event.target as Node)) return;
setShowMenu(false);
setShowColorPicker(false);
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showColorPicker, showMenu]);
const handleNameSubmit = useCallback(() => {
const nextName = editName.trim();
if (nextName && nextName !== group?.name) {
updateGroup(groupId, { name: nextName });
} else {
setEditName(group?.name || "");
}
setIsEditing(false);
}, [editName, group?.name, groupId, updateGroup]);
const handleNameKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key === "Enter") {
handleNameSubmit();
} else if (event.key === "Escape") {
setEditName(group?.name || "");
setIsEditing(false);
}
},
[group?.name, handleNameSubmit]
);
if (!group) return null;
const bgColor = GROUP_COLORS[group.color];
const showResizeControls = selected || isHovered;
return (
<>
<NodeResizer
isVisible={showResizeControls}
minWidth={200}
minHeight={120}
lineClassName="!border-neutral-400/40"
handleClassName="!h-4 !w-4 !border-neutral-300 !bg-neutral-700 !shadow-sm !p-0"
/>
<div
className="h-full w-full overflow-visible rounded-xl"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
backgroundColor: `${bgColor}60`,
border: group.isNbpInput
? "3px dashed rgba(255,255,255,0.25)"
: `1px solid ${bgColor}`,
}}
>
<div
className="nodrag absolute left-0 top-0 flex -translate-y-full items-center gap-1 pb-1"
ref={menuRef}
>
<div
className="flex h-6 items-center rounded-md px-2"
style={{ backgroundColor: bgColor }}
>
{group.locked && (
<svg className="mr-1 h-3 w-3 text-white/70" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 15v2m-6 4h12a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2Zm10-10V7a4 4 0 0 0-8 0v4h8Z" />
</svg>
)}
{isEditing ? (
<input
ref={inputRef}
value={editName}
onChange={(event) => setEditName(event.target.value)}
onBlur={handleNameSubmit}
onKeyDown={handleNameKeyDown}
className="nodrag nopan w-36 border-0 bg-transparent p-0 text-xs font-medium text-white outline-none"
/>
) : (
<button
type="button"
className="nodrag nopan max-w-[200px] truncate text-xs font-medium text-white"
onDoubleClick={() => setIsEditing(true)}
title={group.name}
>
{group.name}
</button>
)}
</div>
<div className="relative">
<button
type="button"
className="nodrag nopan flex h-6 w-6 flex-col items-center justify-center gap-[2px] rounded-md hover:bg-white/20"
onClick={(event) => {
event.stopPropagation();
setShowMenu((open) => !open);
setShowColorPicker(false);
}}
title="Group options"
>
<span className="h-[3px] w-[3px] rounded-full bg-white/70" />
<span className="h-[3px] w-[3px] rounded-full bg-white/70" />
<span className="h-[3px] w-[3px] rounded-full bg-white/70" />
</button>
{showMenu && (
<div className="nodrag nopan absolute bottom-full left-1/2 z-50 mb-1 min-w-[130px] -translate-x-1/2 rounded-lg bg-neutral-800/95 py-1 shadow-lg shadow-black/30">
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-white/80 hover:bg-white/10"
onClick={(event) => {
event.stopPropagation();
setShowColorPicker((open) => !open);
}}
>
<span className="h-3 w-3 rounded-full border border-white/30" style={{ backgroundColor: bgColor }} />
<span>Background</span>
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-white/80 hover:bg-white/10"
onClick={(event) => {
event.stopPropagation();
toggleGroupLock(groupId);
setShowMenu(false);
}}
>
<span>{group.locked ? "Unlock" : "Lock"}</span>
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-white/80 hover:bg-white/10"
onClick={(event) => {
event.stopPropagation();
updateGroup(groupId, { isNbpInput: !group.isNbpInput });
setShowMenu(false);
}}
>
<span>NBP Input</span>
{group.isNbpInput && <span className="ml-auto">On</span>}
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-red-300 hover:bg-red-500/10"
onClick={(event) => {
event.stopPropagation();
deleteGroup(groupId);
}}
>
<span>Delete</span>
</button>
{showColorPicker && (
<div className="absolute left-0 top-0 z-50 flex -translate-x-full gap-1 rounded-lg bg-neutral-800/95 p-2 shadow-lg shadow-black/30">
{COLOR_OPTIONS.map(({ color, label }) => (
<button
key={color}
type="button"
className={`h-6 w-6 rounded-full border-2 ${group.color === color ? "border-white" : "border-transparent"}`}
style={{ backgroundColor: GROUP_COLORS[color] }}
title={label}
onClick={(event) => {
event.stopPropagation();
updateGroup(groupId, { color });
setShowColorPicker(false);
setShowMenu(false);
}}
/>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
</>
);
}