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

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

@ -403,7 +403,7 @@ describe("MultiSelectToolbar", () => {
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) => {
return selector(createDefaultState({
nodes: [
@ -418,17 +418,15 @@ describe("MultiSelectToolbar", () => {
}));
});
render(
const { container } = render(
<TestWrapper>
<MultiSelectToolbar />
</TestWrapper>
);
const createGroupButton = screen.getByTitle("创建分组");
fireEvent.click(createGroupButton);
expect(container.firstChild).toBeNull();
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", () => {

108
src/components/composer/GenerationComposer.tsx

@ -12,10 +12,9 @@ import {
type MouseEvent,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { CloseOutlined, DownOutlined, FileTextOutlined, RightOutlined } from "@ant-design/icons";
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 { useModelStore } from "@/store/modelStore";
import {
@ -2891,49 +2890,74 @@ export function GenerationComposer() {
return t("composer.describeContent");
}, [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 assistMenuOverlay = assistMenu && assistMenuPosition && typeof document !== "undefined"
? createPortal(
<div
className="fixed z-[10080] w-72 overflow-hidden rounded-xl border border-neutral-700 bg-neutral-800 shadow-2xl shadow-black/40"
style={{
left: assistMenuPosition.left,
top: assistMenuPosition.top,
transform: "translateY(-100%)",
const assistMenuOverlay = assistMenu && assistMenuPosition
? (
<Dropdown
open
onOpenChange={(nextOpen) => {
if (nextOpen) return;
assistTriggerIndexRef.current = null;
setAssistMenu(null);
setAssistMenuPosition(null);
}}
onPointerDownCapture={stopCanvasEvent}
onMouseDownCapture={stopCanvasEvent}
onClick={stopCanvasEvent}
onWheelCapture={stopCanvasEvent}
trigger={["click"]}
placement="top"
overlayClassName="z-[10080]"
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">
{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>,
document.body
<div
aria-hidden="true"
className="pointer-events-none fixed z-[10080] h-px w-72"
style={{
left: assistMenuPosition.left,
top: assistMenuPosition.top,
}}
/>
</Dropdown>
)
: null;

119
src/components/nodes/GroupNode.tsx

@ -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"

Loading…
Cancel
Save