Browse Source

改成使用组件库的组件来渲染

feature/260608
TianYun 1 month ago
parent
commit
df1b08065e
  1. 9
      src/components/MultiSelectToolbar.tsx
  2. 10
      src/components/__tests__/MultiSelectToolbar.test.tsx
  3. 108
      src/components/composer/GenerationComposer.tsx
  4. 119
      src/components/nodes/GroupNode.tsx

9
src/components/MultiSelectToolbar.tsx

@ -22,6 +22,11 @@ export function MultiSelectToolbar() {
[selectedNodes] [selectedNodes]
); );
const hasSelectedRegularNode = useMemo(
() => selectedNodes.some((node) => node.type !== "group"),
[selectedNodes]
);
// Check if any selected nodes are in a group // Check if any selected nodes are in a group
const selectedNodeGroups = useMemo(() => { const selectedNodeGroups = useMemo(() => {
const groupIds = new Set(selectedNodes.map((n) => n.groupId).filter(Boolean)); const groupIds = new Set(selectedNodes.map((n) => n.groupId).filter(Boolean));
@ -150,7 +155,9 @@ export function MultiSelectToolbar() {
removeNodesFromGroup(nodeIds); removeNodesFromGroup(nodeIds);
}; };
if (!toolbarPosition || selectedNodes.length < 2) return null; const shouldHideForRegroupIntent = hasSelectedGroupNode && hasSelectedRegularNode;
if (!toolbarPosition || selectedNodes.length < 2 || shouldHideForRegroupIntent) return null;
return ( return (
<div <div

10
src/components/__tests__/MultiSelectToolbar.test.tsx

@ -403,7 +403,7 @@ describe("MultiSelectToolbar", () => {
expect(mockCreateGroup).toHaveBeenCalledWith(["node-1", "node-2"]); expect(mockCreateGroup).toHaveBeenCalledWith(["node-1", "node-2"]);
}); });
it("should show create group button when a selected group is included", () => { it("should not render when selected nodes include a group and regular nodes", () => {
mockUseWorkflowStore.mockImplementation((selector) => { mockUseWorkflowStore.mockImplementation((selector) => {
return selector(createDefaultState({ return selector(createDefaultState({
nodes: [ nodes: [
@ -418,17 +418,15 @@ describe("MultiSelectToolbar", () => {
})); }));
}); });
render( const { container } = render(
<TestWrapper> <TestWrapper>
<MultiSelectToolbar /> <MultiSelectToolbar />
</TestWrapper> </TestWrapper>
); );
const createGroupButton = screen.getByTitle("创建分组"); expect(container.firstChild).toBeNull();
fireEvent.click(createGroupButton); expect(screen.queryByTitle("创建分组")).not.toBeInTheDocument();
expect(screen.queryByTitle("从分组中移除")).not.toBeInTheDocument(); expect(screen.queryByTitle("从分组中移除")).not.toBeInTheDocument();
expect(mockCreateGroup).toHaveBeenCalledWith(["group-node-group-1", "node-3"]);
}); });
it("should show ungroup button when selected nodes are in a group", () => { it("should show ungroup button when selected nodes are in a group", () => {

108
src/components/composer/GenerationComposer.tsx

@ -12,10 +12,9 @@ import {
type MouseEvent, type MouseEvent,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import { createPortal } from "react-dom";
import { CloseOutlined, DownOutlined, FileTextOutlined, RightOutlined } from "@ant-design/icons"; import { CloseOutlined, DownOutlined, FileTextOutlined, RightOutlined } from "@ant-design/icons";
import { useReactFlow, useViewport, ViewportPortal } from "@xyflow/react"; import { useReactFlow, useViewport, ViewportPortal } from "@xyflow/react";
import { message, Modal, Popover, Segmented, Tooltip } from "antd"; import { Dropdown, message, Modal, Popover, Segmented, Tooltip } from "antd";
import { useProviderApiKeys, useWorkflowStore } from "@/store/workflowStore"; import { useProviderApiKeys, useWorkflowStore } from "@/store/workflowStore";
import { useModelStore } from "@/store/modelStore"; import { useModelStore } from "@/store/modelStore";
import { import {
@ -2891,49 +2890,74 @@ export function GenerationComposer() {
return t("composer.describeContent"); return t("composer.describeContent");
}, [connectedInputs, context.mode, context.selectedCount, isEaseCurveNode, isVideoStitchNode, promptForSubmit, t]); }, [connectedInputs, context.mode, context.selectedCount, isEaseCurveNode, isVideoStitchNode, promptForSubmit, t]);
useEffect(() => {
if (shouldRenderInlineComposer) return;
assistTriggerIndexRef.current = null;
setAssistMenu(null);
setAssistMenuPosition(null);
}, [shouldRenderInlineComposer]);
const hideComposerForModal = isModalOpen && !modelDialogOpen; const hideComposerForModal = isModalOpen && !modelDialogOpen;
const assistMenuOverlay = assistMenu && assistMenuPosition && typeof document !== "undefined" const assistMenuOverlay = assistMenu && assistMenuPosition
? createPortal( ? (
<div <Dropdown
className="fixed z-[10080] w-72 overflow-hidden rounded-xl border border-neutral-700 bg-neutral-800 shadow-2xl shadow-black/40" open
style={{ onOpenChange={(nextOpen) => {
left: assistMenuPosition.left, if (nextOpen) return;
top: assistMenuPosition.top, assistTriggerIndexRef.current = null;
transform: "translateY(-100%)", setAssistMenu(null);
setAssistMenuPosition(null);
}} }}
onPointerDownCapture={stopCanvasEvent} trigger={["click"]}
onMouseDownCapture={stopCanvasEvent} placement="top"
onClick={stopCanvasEvent} overlayClassName="z-[10080]"
onWheelCapture={stopCanvasEvent} menu={{ items: [] }}
popupRender={() => (
<div
className="w-72 overflow-hidden rounded-xl border border-neutral-700 bg-neutral-800 shadow-2xl shadow-black/40"
onPointerDownCapture={stopCanvasEvent}
onMouseDownCapture={stopCanvasEvent}
onClick={stopCanvasEvent}
onWheelCapture={stopCanvasEvent}
>
<div className="border-b border-neutral-700 px-3 py-2 text-[11px] font-medium text-neutral-500">
{assistMenu === "command" ? t("composer.commands") : t("composer.referenceMaterials")}
</div>
<div className="py-1">
{activeAssistItems.map((item, itemIndex) => {
const isActive = itemIndex === assistMenuActiveIndex;
return (
<button
key={item.key}
type="button"
onMouseEnter={() => setAssistMenuActiveIndex(itemIndex)}
onClick={() => selectAssistMenuItem(item)}
className={`flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs transition-colors ${isActive
? "bg-neutral-700 text-neutral-100"
: "text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100"
}`}
>
<span className="flex min-w-0 items-center gap-2">
{item.image && <img src={item.image} alt="" className="h-8 w-8 rounded object-cover" />}
<span className="truncate">{item.label}</span>
</span>
<RightOutlined className="text-neutral-500" />
</button>
);
})}
</div>
</div>
)}
> >
<div className="border-b border-neutral-700 px-3 py-2 text-[11px] font-medium text-neutral-500"> <div
{assistMenu === "command" ? t("composer.commands") : t("composer.referenceMaterials")} aria-hidden="true"
</div> className="pointer-events-none fixed z-[10080] h-px w-72"
<div className="py-1"> style={{
{activeAssistItems.map((item, itemIndex) => { left: assistMenuPosition.left,
const isActive = itemIndex === assistMenuActiveIndex; top: assistMenuPosition.top,
return ( }}
<button />
key={item.key} </Dropdown>
type="button"
onMouseEnter={() => setAssistMenuActiveIndex(itemIndex)}
onClick={() => selectAssistMenuItem(item)}
className={`flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-xs transition-colors ${isActive
? "bg-neutral-700 text-neutral-100"
: "text-neutral-300 hover:bg-neutral-700 hover:text-neutral-100"
}`}
>
<span className="flex min-w-0 items-center gap-2">
{item.image && <img src={item.image} alt="" className="h-8 w-8 rounded object-cover" />}
<span className="truncate">{item.label}</span>
</span>
<RightOutlined className="text-neutral-500" />
</button>
);
})}
</div>
</div>,
document.body
) )
: null; : null;

119
src/components/nodes/GroupNode.tsx

@ -10,7 +10,7 @@ import {
Position, Position,
} from "@xyflow/react"; } from "@xyflow/react";
import { DownloadOutlined } from "@ant-design/icons"; import { DownloadOutlined } from "@ant-design/icons";
import { Modal } from "antd"; import { Dropdown, Modal, Popover, type MenuProps } from "antd";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import type { TranslationKey } from "@/i18n"; import type { TranslationKey } from "@/i18n";
import { GROUP_COLORS, useWorkflowStore } from "@/store/workflowStore"; 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 imageRefBasePath = useWorkflowStore((state) => state.imageRefBasePath ?? null);
const [isEditing, setIsEditing] = useState(false); const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(group?.name || ""); 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 [isDownloading, setIsDownloading] = useState(false);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const toolbarRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!isEditing) setEditName(group?.name || ""); if (!isEditing) setEditName(group?.name || "");
@ -77,18 +76,6 @@ export function GroupNode({ id, data, selected }: NodeProps<GroupNodeType>) {
} }
}, [isEditing]); }, [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 handleNameSubmit = useCallback(() => {
const nextName = editName.trim(); const nextName = editName.trim();
if (nextName && nextName !== group?.name) { 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); const groupNodes = nodes.filter((node) => node.type !== "group" && node.groupId === groupId);
if (groupNodes.length < 2) { if (groupNodes.length < 2) {
updateGroup(groupId, { layout }); updateGroup(groupId, { layout });
setActivePopover(null);
return; return;
} }
@ -165,7 +151,6 @@ export function GroupNode({ id, data, selected }: NodeProps<GroupNodeType>) {
updateGroup(groupId, { layout }); updateGroup(groupId, { layout });
onNodesChange(changes); onNodesChange(changes);
setActivePopover(null);
}, },
[groupId, nodes, onNodesChange, updateGroup] [groupId, nodes, onNodesChange, updateGroup]
); );
@ -238,6 +223,37 @@ export function GroupNode({ id, data, selected }: NodeProps<GroupNodeType>) {
const bgColor = GROUP_COLORS[group.color]; const bgColor = GROUP_COLORS[group.color];
const showResizeControls = selected || isHovered; 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 ( return (
<> <>
@ -250,50 +266,45 @@ export function GroupNode({ id, data, selected }: NodeProps<GroupNodeType>) {
> >
<div <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" 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 <button
type="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" 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) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
setActivePopover((current) => (current === "color" ? null : "color"));
}} }}
title={t("group.color")} title={t("group.color")}
aria-label={t("group.color")} aria-label={t("group.color")}
> >
<span className="h-4 w-4 rounded-full border border-white/30" style={{ backgroundColor: bgColor }} /> <span className="h-4 w-4 rounded-full border border-white/30" style={{ backgroundColor: bgColor }} />
</button> </button>
</Popover>
{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"> <Dropdown
{COLOR_OPTIONS.map(({ color, labelKey }) => ( trigger={["click"]}
<button placement="top"
key={color} menu={{
type="button" items: layoutMenuItems,
className={`h-6 w-6 rounded-full border-2 ${group.color === color ? "border-white" : "border-transparent"}`} selectedKeys: group.layout ? [group.layout] : [],
style={{ backgroundColor: GROUP_COLORS[color] }} onClick: ({ key, domEvent }) => {
title={t(labelKey)} domEvent.stopPropagation();
aria-label={t(labelKey)} handleApplyLayout(key as GroupLayout);
onClick={(event) => { },
event.stopPropagation(); }}
updateGroup(groupId, { color }); >
setActivePopover(null);
}}
/>
))}
</div>
)}
</div>
<div className="relative shrink-0">
<button <button
type="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" 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) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
setActivePopover((current) => (current === "layout" ? null : "layout"));
}} }}
title={t("group.layout")} title={t("group.layout")}
aria-label={t("group.layout")} aria-label={t("group.layout")}
@ -310,25 +321,7 @@ export function GroupNode({ id, data, selected }: NodeProps<GroupNodeType>) {
</svg> </svg>
</button> </button>
{activePopover === "layout" && ( </Dropdown>
<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>
<button <button
type="button" type="button"

Loading…
Cancel
Save