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
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>
|
|
</>
|
|
);
|
|
}
|
|
|