|
|
|
@ -10,7 +10,7 @@ import { |
|
|
|
Position, |
|
|
|
} from "@xyflow/react"; |
|
|
|
import { DownloadOutlined } from "@ant-design/icons"; |
|
|
|
import { Modal } from "antd"; |
|
|
|
import { Dropdown, Modal, Popover, type MenuProps } from "antd"; |
|
|
|
import { useI18n } from "@/i18n"; |
|
|
|
import type { TranslationKey } from "@/i18n"; |
|
|
|
import { GROUP_COLORS, useWorkflowStore } from "@/store/workflowStore"; |
|
|
|
@ -60,11 +60,10 @@ export function GroupNode({ id, data, selected }: NodeProps<GroupNodeType>) { |
|
|
|
const imageRefBasePath = useWorkflowStore((state) => state.imageRefBasePath ?? null); |
|
|
|
const [isEditing, setIsEditing] = useState(false); |
|
|
|
const [editName, setEditName] = useState(group?.name || ""); |
|
|
|
const [activePopover, setActivePopover] = useState<"color" | "layout" | null>(null); |
|
|
|
const [colorPopoverOpen, setColorPopoverOpen] = useState(false); |
|
|
|
const [isDownloading, setIsDownloading] = useState(false); |
|
|
|
const [isHovered, setIsHovered] = useState(false); |
|
|
|
const inputRef = useRef<HTMLInputElement>(null); |
|
|
|
const toolbarRef = useRef<HTMLDivElement>(null); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (!isEditing) setEditName(group?.name || ""); |
|
|
|
@ -77,18 +76,6 @@ export function GroupNode({ id, data, selected }: NodeProps<GroupNodeType>) { |
|
|
|
} |
|
|
|
}, [isEditing]); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
if (!activePopover) return; |
|
|
|
|
|
|
|
const handleClickOutside = (event: MouseEvent) => { |
|
|
|
if (toolbarRef.current?.contains(event.target as Node)) return; |
|
|
|
setActivePopover(null); |
|
|
|
}; |
|
|
|
|
|
|
|
document.addEventListener("mousedown", handleClickOutside); |
|
|
|
return () => document.removeEventListener("mousedown", handleClickOutside); |
|
|
|
}, [activePopover]); |
|
|
|
|
|
|
|
const handleNameSubmit = useCallback(() => { |
|
|
|
const nextName = editName.trim(); |
|
|
|
if (nextName && nextName !== group?.name) { |
|
|
|
@ -116,7 +103,6 @@ export function GroupNode({ id, data, selected }: NodeProps<GroupNodeType>) { |
|
|
|
const groupNodes = nodes.filter((node) => node.type !== "group" && node.groupId === groupId); |
|
|
|
if (groupNodes.length < 2) { |
|
|
|
updateGroup(groupId, { layout }); |
|
|
|
setActivePopover(null); |
|
|
|
return; |
|
|
|
} |
|
|
|
|
|
|
|
@ -165,7 +151,6 @@ export function GroupNode({ id, data, selected }: NodeProps<GroupNodeType>) { |
|
|
|
|
|
|
|
updateGroup(groupId, { layout }); |
|
|
|
onNodesChange(changes); |
|
|
|
setActivePopover(null); |
|
|
|
}, |
|
|
|
[groupId, nodes, onNodesChange, updateGroup] |
|
|
|
); |
|
|
|
@ -238,6 +223,37 @@ export function GroupNode({ id, data, selected }: NodeProps<GroupNodeType>) { |
|
|
|
|
|
|
|
const bgColor = GROUP_COLORS[group.color]; |
|
|
|
const showResizeControls = selected || isHovered; |
|
|
|
const colorPopoverContent = ( |
|
|
|
<div |
|
|
|
className="nodrag nopan flex gap-1" |
|
|
|
onClick={(event) => event.stopPropagation()} |
|
|
|
onPointerDown={(event) => event.stopPropagation()} |
|
|
|
> |
|
|
|
{COLOR_OPTIONS.map(({ color, labelKey }) => ( |
|
|
|
<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={t(labelKey)} |
|
|
|
aria-label={t(labelKey)} |
|
|
|
onClick={(event) => { |
|
|
|
event.stopPropagation(); |
|
|
|
updateGroup(groupId, { color }); |
|
|
|
setColorPopoverOpen(false); |
|
|
|
}} |
|
|
|
/> |
|
|
|
))} |
|
|
|
</div> |
|
|
|
); |
|
|
|
const layoutMenuItems: MenuProps["items"] = LAYOUT_OPTIONS.map(({ layout, labelKey }) => ({ |
|
|
|
key: layout, |
|
|
|
label: ( |
|
|
|
<span className="flex min-w-20 items-center gap-3 text-[11px]"> |
|
|
|
<span className="whitespace-nowrap">{t(labelKey)}</span> |
|
|
|
</span> |
|
|
|
), |
|
|
|
})); |
|
|
|
|
|
|
|
return ( |
|
|
|
<> |
|
|
|
@ -250,50 +266,45 @@ export function GroupNode({ id, data, selected }: NodeProps<GroupNodeType>) { |
|
|
|
> |
|
|
|
<div |
|
|
|
className="nodrag nopan relative z-[10050] flex h-12 shrink-0 flex-nowrap items-center justify-center gap-1 rounded-xl border border-neutral-700/80 bg-neutral-800/95 px-3 py-2 shadow-2xl shadow-black/40" |
|
|
|
ref={toolbarRef} |
|
|
|
> |
|
|
|
<div className="relative shrink-0"> |
|
|
|
<Popover |
|
|
|
arrow={false} |
|
|
|
content={colorPopoverContent} |
|
|
|
open={colorPopoverOpen} |
|
|
|
placement="top" |
|
|
|
trigger="click" |
|
|
|
onOpenChange={setColorPopoverOpen} |
|
|
|
> |
|
|
|
<button |
|
|
|
type="button" |
|
|
|
className="nodrag nopan flex h-8 w-8 shrink-0 items-center justify-center rounded text-neutral-400 transition-colors hover:bg-neutral-700 hover:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-neutral-500" |
|
|
|
onClick={(event) => { |
|
|
|
event.stopPropagation(); |
|
|
|
setActivePopover((current) => (current === "color" ? null : "color")); |
|
|
|
}} |
|
|
|
title={t("group.color")} |
|
|
|
aria-label={t("group.color")} |
|
|
|
> |
|
|
|
<span className="h-4 w-4 rounded-full border border-white/30" style={{ backgroundColor: bgColor }} /> |
|
|
|
</button> |
|
|
|
|
|
|
|
{activePopover === "color" && ( |
|
|
|
<div className="nodrag nopan absolute bottom-[calc(100%+8px)] left-1/2 z-[10060] flex -translate-x-1/2 gap-1 rounded-lg border border-neutral-700/80 bg-neutral-800/95 p-2 shadow-xl shadow-black/30"> |
|
|
|
{COLOR_OPTIONS.map(({ color, labelKey }) => ( |
|
|
|
<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={t(labelKey)} |
|
|
|
aria-label={t(labelKey)} |
|
|
|
onClick={(event) => { |
|
|
|
event.stopPropagation(); |
|
|
|
updateGroup(groupId, { color }); |
|
|
|
setActivePopover(null); |
|
|
|
}} |
|
|
|
/> |
|
|
|
))} |
|
|
|
</div> |
|
|
|
)} |
|
|
|
</div> |
|
|
|
|
|
|
|
<div className="relative shrink-0"> |
|
|
|
</Popover> |
|
|
|
|
|
|
|
<Dropdown |
|
|
|
trigger={["click"]} |
|
|
|
placement="top" |
|
|
|
menu={{ |
|
|
|
items: layoutMenuItems, |
|
|
|
selectedKeys: group.layout ? [group.layout] : [], |
|
|
|
onClick: ({ key, domEvent }) => { |
|
|
|
domEvent.stopPropagation(); |
|
|
|
handleApplyLayout(key as GroupLayout); |
|
|
|
}, |
|
|
|
}} |
|
|
|
> |
|
|
|
<button |
|
|
|
type="button" |
|
|
|
className="nodrag nopan flex h-8 w-[92px] shrink-0 items-center justify-center gap-1 rounded px-2 text-[11px] font-medium text-neutral-400 transition-colors hover:bg-neutral-700 hover:text-neutral-100 focus:outline-none focus:ring-1 focus:ring-neutral-500" |
|
|
|
onClick={(event) => { |
|
|
|
event.stopPropagation(); |
|
|
|
setActivePopover((current) => (current === "layout" ? null : "layout")); |
|
|
|
}} |
|
|
|
title={t("group.layout")} |
|
|
|
aria-label={t("group.layout")} |
|
|
|
@ -310,25 +321,7 @@ export function GroupNode({ id, data, selected }: NodeProps<GroupNodeType>) { |
|
|
|
</svg> |
|
|
|
</button> |
|
|
|
|
|
|
|
{activePopover === "layout" && ( |
|
|
|
<div className="nodrag nopan absolute bottom-[calc(100%+8px)] left-1/2 z-[10060] w-28 -translate-x-1/2 rounded-lg border border-neutral-700/80 bg-neutral-800/95 py-1 shadow-xl shadow-black/30"> |
|
|
|
{LAYOUT_OPTIONS.map(({ layout, labelKey }) => ( |
|
|
|
<button |
|
|
|
key={layout} |
|
|
|
type="button" |
|
|
|
className="flex w-full items-center px-3 py-1.5 text-left text-[11px] text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100" |
|
|
|
onClick={(event) => { |
|
|
|
event.stopPropagation(); |
|
|
|
handleApplyLayout(layout); |
|
|
|
}} |
|
|
|
> |
|
|
|
<span className="whitespace-nowrap">{t(labelKey)}</span> |
|
|
|
{group.layout === layout && <span className="ml-auto text-neutral-100">✓</span>} |
|
|
|
</button> |
|
|
|
))} |
|
|
|
</div> |
|
|
|
)} |
|
|
|
</div> |
|
|
|
</Dropdown> |
|
|
|
|
|
|
|
<button |
|
|
|
type="button" |
|
|
|
|