From 0fd8084e788f0f03dd775432036f8cd0c9bfa242 Mon Sep 17 00:00:00 2001 From: yun Date: Sat, 4 Jul 2026 14:54:07 +0800 Subject: [PATCH] =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=B1=82=E7=BA=A7=E5=A4=84?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/globals.css | 6 - src/components/MultiAngleEditor.tsx | 2 +- src/components/WorkflowCanvas.tsx | 388 +++++++----------- .../__tests__/FloatingNodeHeader.test.tsx | 10 - src/components/__tests__/GroupNode.test.tsx | 2 + src/components/media/MediaCropOverlay.tsx | 22 +- src/components/media/MediaEditToolbar.tsx | 2 +- src/components/media/SplitGridCapsuleMenu.tsx | 4 +- .../media/SplitGridSelectionOverlay.tsx | 6 +- .../media/SplitGridSelectionToolbar.tsx | 2 +- src/components/nodes/BaseNode.tsx | 57 +++ src/components/nodes/FloatingNodeHeader.tsx | 15 +- src/components/nodes/NodeHeaderContext.tsx | 15 + src/components/nodes/nodeHeaderTitle.ts | 92 +++++ 14 files changed, 332 insertions(+), 291 deletions(-) create mode 100644 src/components/nodes/NodeHeaderContext.tsx create mode 100644 src/components/nodes/nodeHeaderTitle.ts diff --git a/src/app/globals.css b/src/app/globals.css index 47bb11f0..155d1b13 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -181,12 +181,6 @@ body { .react-flow__node { font-family: inherit; - z-index: 2; -} - -.react-flow__node.selected, -.react-flow__node.dragging { - z-index: 10000; } /* Skip hit-testing on images inside nodes — parent containers handle all diff --git a/src/components/MultiAngleEditor.tsx b/src/components/MultiAngleEditor.tsx index 33359f8f..6a9c3455 100644 --- a/src/components/MultiAngleEditor.tsx +++ b/src/components/MultiAngleEditor.tsx @@ -360,7 +360,7 @@ export function MultiAngleEditor({ transform: `scale(${previewScale}) rotateX(${cubeRotateX}deg) rotateY(${cubeRotateY}deg)`, }} > -
+
{previewImage ? ( ({ nodes: state.nodes, @@ -575,6 +574,7 @@ export function WorkflowCanvas() { const [isBuildingWorkflow, setIsBuildingWorkflow] = useState(false); const [isMiniMapOpen, setIsMiniMapOpen] = useState(false); const [zoomPercent, setZoomPercent] = useState(100); + const [draggingGroupId, setDraggingGroupId] = useState(null); const canvasGridColor = "var(--canvas-grid)"; const miniMapMaskColor = "var(--surface-overlay)"; const miniMapNodeColor = "var(--text-disabled)"; @@ -875,14 +875,20 @@ export function WorkflowCanvas() { } }, [tutorialActive, nodes, setCenter]); - // Apply dimming className to nodes downstream of disabled Switch outputs or skipped by optional inputs + // Apply visual state that React Flow can turn into DOM classes and z-index. const allNodes = useMemo(() => { + const draggingGroupNodeId = draggingGroupId ? `group-node-${draggingGroupId}` : null; + return nodes.map((node) => { // Never dim Switch or ConditionalSwitch nodes themselves - if (node.type === "switch" || node.type === "conditionalSwitch") return node; - - const isDimmed = dimmedNodeIds.has(node.id); - const isSkipped = skippedNodeIds.has(node.id); + const canDimNode = node.type !== "switch" && node.type !== "conditionalSwitch"; + const isDraggingGroupShell = Boolean(draggingGroupNodeId && node.id === draggingGroupNodeId); + const isDraggingGroupMember = Boolean(draggingGroupId && node.type !== "group" && ( + node.groupId === draggingGroupId || node.parentId === draggingGroupNodeId + )); + + const isDimmed = canDimNode && dimmedNodeIds.has(node.id); + const isSkipped = canDimNode && skippedNodeIds.has(node.id); const extraClasses = [ isDimmed ? "switch-dimmed" : "", isSkipped ? "node-skipped" : "", @@ -891,12 +897,23 @@ export function WorkflowCanvas() { // Preserve existing className if any, add/remove dimmed/skipped classes const baseClass = (node.className || "").replace(/\bswitch-dimmed\b/g, "").replace(/\bnode-skipped\b/g, "").trim(); const newClass = extraClasses ? `${baseClass} ${extraClasses}`.trim() : baseClass; + const zIndex = node.type === "group" + ? isDraggingGroupShell + ? DRAGGING_GROUP_Z_INDEX + : GROUP_NODE_Z_INDEX + : isDraggingGroupMember + ? DRAGGING_GROUP_MEMBER_Z_INDEX + : node.dragging + ? DRAGGING_NODE_Z_INDEX + : node.selected + ? SELECTED_NODE_Z_INDEX + : DEFAULT_NODE_Z_INDEX; // Only create new node object if className changed - if (node.className === newClass) return node; - return { ...node, className: newClass }; + if (node.className === newClass && node.zIndex === zIndex) return node; + return { ...node, className: newClass, zIndex }; }); - }, [nodes, dimmedNodeIds, skippedNodeIds]); + }, [nodes, dimmedNodeIds, draggingGroupId, skippedNodeIds]); const absoluteNodePositions = useMemo(() => { const byId = new Map(allNodes.map((node) => [node.id, node])); @@ -937,7 +954,6 @@ export function WorkflowCanvas() { [allNodes] ); const isSingleCanvasSelection = selectedCanvasNodes.length === 1; - const isMultiCanvasSelection = selectedCanvasNodes.length > 1; const selectedComposerTarget = useMemo(() => { if (isPreviewMode) return null; if (!isSingleCanvasSelection) return null; @@ -1025,86 +1041,6 @@ export function WorkflowCanvas() { }; }, [getViewport, multiSelectionBounds]); - // Node title mapping for FloatingNodeHeaders - const NODE_TITLES = useMemo>(() => ({ - imageInput: t("node.imageInput"), - smartImage: t("node.image"), - smartVideo: t("node.generateVideo"), - smartAudio: t("toolbar.audio"), - audioInput: t("node.audioInput"), - videoInput: t("node.videoInput"), - annotation: t("node.annotation"), - prompt: t("node.prompt"), - smartText: t("smartText.title"), - array: t("node.array"), - promptConstructor: t("node.promptConstructor"), - nanoBanana: t("node.generateImage"), - generateVideo: t("node.generateVideo"), - generate3d: t("node.generate3d"), - generateAudio: t("node.generateAudio"), - llmGenerate: t("node.llmGenerate"), - output: t("node.output"), - outputGallery: t("node.outputGallery"), - imageCompare: t("node.imageCompare"), - videoStitch: t("node.videoStitch"), - easeCurve: t("node.easeCurve"), - videoTrim: t("node.videoTrim"), - videoFrameGrab: t("node.frameGrab"), - router: t("node.router"), - switch: t("node.switch"), - conditionalSwitch: t("node.conditionalSwitch"), - glbViewer: t("node.glbViewer"), - }), [t]); - - // Helper to get node title (used for FloatingNodeHeader) - const getEffectiveGenerationModel = useCallback((node: Node) => { - const data = node.data as any; - const smartImageGenerate = node.type === "smartImage" && isSmartImageGenerateMode(node.id, data, edges); - const smartAudioGenerate = node.type === "smartAudio" && isSmartAudioGenerateMode(node.id, data, edges); - const smartVideoGenerate = node.type === "smartVideo" && isSmartVideoGenerateMode(node.id, data, edges); - const hasOutput = smartImageGenerate || node.type === "nanoBanana" - ? data?.outputImage - : smartAudioGenerate || node.type === "generateAudio" - ? data?.outputAudio - : data?.outputVideo; - if ((smartImageGenerate || smartVideoGenerate || node.type === "nanoBanana" || node.type === "generateVideo") && hasOutput && data?.lastUsedModel) { - return data.lastUsedModel; - } - return data?.selectedModel; - }, [edges]); - - const getNodeTitle = useCallback((node: Node) => { - const data = node.data as any; - const smartImageGenerate = node.type === "smartImage" && isSmartImageGenerateMode(node.id, data, edges); - const smartAudioGenerate = node.type === "smartAudio" && isSmartAudioGenerateMode(node.id, data, edges); - const smartVideoGenerate = node.type === "smartVideo" && isSmartVideoGenerateMode(node.id, data, edges); - if (smartImageGenerate || smartVideoGenerate || node.type === "nanoBanana" || node.type === "generateVideo") { - const model = getEffectiveGenerationModel(node); - if (model?.displayName) return model.displayName; - } - - // For generate nodes, check for selectedModel display name - if (smartAudioGenerate || node.type === "generate3d" || node.type === "generateAudio") { - const model = (node.data as any)?.selectedModel; - if (model?.displayName) return model.displayName; - } - - // For LLM nodes, check for selectedLLMModel or selectedModel - if (node.type === "llmGenerate" || node.type === "smartText") { - const model = (node.data as any)?.selectedLLMModel || (node.data as any)?.selectedModel; - if (model?.displayName) return model.displayName; - if (model?.name) return model.name; - } - - return NODE_TITLES[node.type || ""] || t("node.generic"); - }, [NODE_TITLES, edges, getEffectiveGenerationModel, t]); - - - // Wire title change callbacks for FloatingNodeHeaders - const handleCustomTitleChange = useCallback((nodeId: string, title: string) => { - updateNodeData(nodeId, { customTitle: title || undefined }); - }, [updateNodeData]); - const openQuickAddForNode = useCallback(( node: Node, clientPosition: { x: number; y: number }, @@ -1307,9 +1243,6 @@ export function WorkflowCanvas() { setCanvasNodeMenu(null); }, [addNode, buildDefaultGenerationNodeCreation, canvasNodeMenu, selectSingleNode]); - // Inline parameters mode (for showing Browse in header) - const { inlineParametersEnabled } = useInlineParameters(); - // Stable callback for expanding a node from its header const handleExpandNode = useCallback((nodeId: string, nodeType: string) => { if (nodeType === 'annotation') { @@ -3106,153 +3039,116 @@ export function WorkflowCanvas() { )} - { isPanningRef.current = true; setHoveredNodeId(null); document.documentElement.classList.add("canvas-interacting"); }} - onMoveEnd={() => { isPanningRef.current = false; document.documentElement.classList.remove("canvas-interacting"); syncZoomPercent(); }} - onNodeDragStart={() => { isDraggingNodeRef.current = true; document.documentElement.classList.add("canvas-interacting"); }} - onNodeDragStop={(event, node) => { isDraggingNodeRef.current = false; document.documentElement.classList.remove("canvas-interacting"); handleNodeDragStop(event, node); }} - onPaneContextMenu={openCanvasContextMenu} - onNodeContextMenu={openNodeContextMenu} - onSelectionStart={handleSelectionStart} - onSelectionEnd={handleSelectionEnd} - onSelectionChange={handleSelectionChange} - nodeTypes={workflowNodeTypes} - edgeTypes={workflowEdgeTypes} - isValidConnection={isValidConnection} - onlyRenderVisibleElements - fitView - fitViewOptions={FIT_VIEW_OPTIONS} - deleteKeyCode={["Backspace", "Delete"]} - multiSelectionKeyCode="Shift" - selectionOnDrag={isTrackpadInputMode} - selectionKeyCode={["Space", "Control", "Meta"]} - panOnDrag={canvasPanOnDrag} - selectNodesOnDrag={false} - nodesFocusable={false} - nodeDragThreshold={5} - nodeClickDistance={5} - zoomOnScroll={tutorialActive ? false : false} - zoomOnPinch={tutorialActive ? false : !isModalOpen} - zoomOnDoubleClick={false} - minZoom={CANVAS_MIN_ZOOM} - maxZoom={CANVAS_MAX_ZOOM} - defaultViewport={{ x: 0, y: 0, zoom: 1 }} - panActivationKeyCode={null} - nodesDraggable={!isModalOpen} - nodesConnectable={!isModalOpen} - elementsSelectable={!isModalOpen} - className="canvas-background bg-[var(--canvas-bg)] text-[var(--text-primary)] [&_.react-flow__selection]:!hidden [&_.react-flow__nodesselection-rect]:!hidden" - proOptions={{ hideAttribution: true }} - defaultEdgeOptions={{ - type: "editable", - animated: false, - }} - > - - - {isMiniMapOpen && ( - - )} - - {boxSelectionRect && ( -
- )} - {!boxSelectionRect && multiSelectionBounds && ( -
+ { isPanningRef.current = true; setHoveredNodeId(null); document.documentElement.classList.add("canvas-interacting"); }} + onMoveEnd={() => { isPanningRef.current = false; document.documentElement.classList.remove("canvas-interacting"); syncZoomPercent(); }} + onNodeDragStart={(_event, node) => { + isDraggingNodeRef.current = true; + document.documentElement.classList.add("canvas-interacting"); + setDraggingGroupId(node.type === "group" ? (node.data as { groupId?: string }).groupId ?? null : null); + }} + onNodeDragStop={(event, node) => { + isDraggingNodeRef.current = false; + document.documentElement.classList.remove("canvas-interacting"); + setDraggingGroupId(null); + handleNodeDragStop(event, node); + }} + onPaneContextMenu={openCanvasContextMenu} + onNodeContextMenu={openNodeContextMenu} + onSelectionStart={handleSelectionStart} + onSelectionEnd={handleSelectionEnd} + onSelectionChange={handleSelectionChange} + nodeTypes={workflowNodeTypes} + edgeTypes={workflowEdgeTypes} + isValidConnection={isValidConnection} + onlyRenderVisibleElements + fitView + fitViewOptions={FIT_VIEW_OPTIONS} + deleteKeyCode={["Backspace", "Delete"]} + multiSelectionKeyCode="Shift" + selectionOnDrag={isTrackpadInputMode} + selectionKeyCode={["Space", "Control", "Meta"]} + panOnDrag={canvasPanOnDrag} + selectNodesOnDrag={false} + elevateNodesOnSelect={false} + nodesFocusable={false} + nodeDragThreshold={5} + nodeClickDistance={5} + zoomOnScroll={tutorialActive ? false : false} + zoomOnPinch={tutorialActive ? false : !isModalOpen} + zoomOnDoubleClick={false} + minZoom={CANVAS_MIN_ZOOM} + maxZoom={CANVAS_MAX_ZOOM} + defaultViewport={{ x: 0, y: 0, zoom: 1 }} + panActivationKeyCode={null} + nodesDraggable={!isModalOpen} + nodesConnectable={!isModalOpen} + elementsSelectable={!isModalOpen} + className="canvas-background bg-[var(--canvas-bg)] text-[var(--text-primary)] [&_.react-flow__selection]:!hidden [&_.react-flow__nodesselection-rect]:!hidden" + proOptions={{ hideAttribution: true }} + defaultEdgeOptions={{ + type: "editable", + animated: false, + }} + > + + - )} - {allNodes.map((node) => { - if (isMultiCanvasSelection) return null; - // Groups don't get floating headers - if (node.type === "group" as any) return null; - - const defaultWidth = defaultNodeDimensions[node.type as NodeType]?.width ?? 250; - const headerWidth = node.measured?.width || (node.style?.width as number) || defaultWidth; - const smartImageGenerate = node.type === "smartImage" && isSmartImageGenerateMode(node.id, node.data as any, edges); - const smartAudioGenerate = node.type === "smartAudio" && isSmartAudioGenerateMode(node.id, node.data as any, edges); - const smartVideoGenerate = node.type === "smartVideo" && isSmartVideoGenerateMode(node.id, node.data as any, edges); - const smartTextGenerate = node.type === "smartText" && isSmartTextLlmMode(node.id, node.data as any, edges); - - // Browse button for generate nodes in inline-parameters mode - const showBrowse = inlineParametersEnabled && ( - smartImageGenerate || smartAudioGenerate || smartVideoGenerate || node.type === "nanoBanana" || node.type === "generateVideo" || - node.type === "generate3d" || node.type === "generateAudio" - ); - const browseAction = showBrowse ? ( - - ) : undefined; - - const effectiveModel = getEffectiveGenerationModel(node); - const nodeTitle = getNodeTitle(node); - const mediaMeta = getNodeHeaderMediaMeta(node, nodeTitle, edges); - const customTitle = typeof node.data?.customTitle === "string" ? node.data.customTitle : undefined; - return ( - - ); - })} - {globalComposerTarget ? ( - - ) : null} - - + )} + + {boxSelectionRect && ( +
+ )} + {!boxSelectionRect && multiSelectionBounds && ( +
+ )} + {globalComposerTarget ? ( + + ) : null} + + +
{ { @@ -58,8 +54,6 @@ describe("FloatingNodeHeader", () => { { { diff --git a/src/components/__tests__/GroupNode.test.tsx b/src/components/__tests__/GroupNode.test.tsx index ac63d46d..8c253b2d 100644 --- a/src/components/__tests__/GroupNode.test.tsx +++ b/src/components/__tests__/GroupNode.test.tsx @@ -8,6 +8,7 @@ const mockDeleteGroup = vi.fn(); const mockOnNodesChange = vi.fn(); const mockRunGroup = vi.fn(); const mockCancelGroupTask = vi.fn(); +const mockSetHoveredNodeId = vi.fn(); const mockUseWorkflowStore = vi.fn(); vi.mock("@/store/workflowStore", () => ({ @@ -112,6 +113,7 @@ describe("GroupNode", () => { deleteGroup: mockDeleteGroup, runGroup: mockRunGroup, cancelGroupTask: mockCancelGroupTask, + setHoveredNodeId: mockSetHoveredNodeId, runningGroupIds: new Set(), }); }); diff --git a/src/components/media/MediaCropOverlay.tsx b/src/components/media/MediaCropOverlay.tsx index fda1cd6e..8e9501c8 100644 --- a/src/components/media/MediaCropOverlay.tsx +++ b/src/components/media/MediaCropOverlay.tsx @@ -385,21 +385,21 @@ export function MediaCropOverlay({ > {bounds && crop && ( <> -
-
-
-
+
+
+
+
handlePointerDown(event, "move")} > -
-
-
-
+
+
+
+
{(["nw", "ne", "sw", "se"] as const).map((mode) => ( diff --git a/src/components/media/SplitGridCapsuleMenu.tsx b/src/components/media/SplitGridCapsuleMenu.tsx index 969bef33..86e76ef7 100644 --- a/src/components/media/SplitGridCapsuleMenu.tsx +++ b/src/components/media/SplitGridCapsuleMenu.tsx @@ -69,8 +69,8 @@ export function SplitGridCapsuleMenu({ busy = false, onSelect }: SplitGridCapsul className={[ "h-7 w-7 rounded-[4px] border transition-colors disabled:cursor-wait", active - ? "border-sky-400 bg-sky-400/70" - : "border-transparent bg-neutral-600 hover:bg-neutral-500", + ? "border-[var(--accent)] bg-[var(--accent-soft)]" + : "border-transparent bg-[var(--surface-active)] hover:bg-[var(--surface-hover)]", ].join(" ")} /> ); diff --git a/src/components/media/SplitGridSelectionOverlay.tsx b/src/components/media/SplitGridSelectionOverlay.tsx index 60f82fc9..7f60470f 100644 --- a/src/components/media/SplitGridSelectionOverlay.tsx +++ b/src/components/media/SplitGridSelectionOverlay.tsx @@ -132,13 +132,13 @@ export function SplitGridSelectionOverlay({ onToggleCell(cellKey); }} className={[ - "relative min-h-0 min-w-0 border border-white/35 transition-colors", - selected ? "border-2 !border-sky-400 bg-black/35" : "hover:bg-white/10", + "relative min-h-0 min-w-0 border border-[var(--text-on-overlay)]/35 transition-colors", + selected ? "border-2 !border-[var(--accent)] bg-[var(--accent-soft)]" : "hover:bg-[var(--accent-soft)]", busy ? "cursor-wait" : "cursor-pointer", ].join(" ")} > {selected ? ( - + {cellKey} ) : null} diff --git a/src/components/media/SplitGridSelectionToolbar.tsx b/src/components/media/SplitGridSelectionToolbar.tsx index 25a8be3d..8f7df6e1 100644 --- a/src/components/media/SplitGridSelectionToolbar.tsx +++ b/src/components/media/SplitGridSelectionToolbar.tsx @@ -41,7 +41,7 @@ export function SplitGridSelectionToolbar({ type="button" disabled={busy || selectedCount === 0} onClick={onConfirm} - className="flex h-9 shrink-0 items-center justify-center rounded-lg bg-white px-4 text-sm font-semibold text-black hover:bg-neutral-200 disabled:cursor-not-allowed disabled:opacity-50" + className="flex h-9 shrink-0 items-center justify-center rounded-lg bg-[var(--accent)] px-4 text-sm font-semibold text-[var(--text-on-overlay)] hover:bg-[var(--accent-hover)] disabled:cursor-not-allowed disabled:opacity-50" > {busy ? "切割中..." : "确认切割"} diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index e47a5505..80548372 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -5,6 +5,18 @@ import { Node, useReactFlow } from "@xyflow/react"; import { useWorkflowStore } from "@/store/workflowStore"; import { isPanningRef, isDraggingNodeRef } from "@/components/WorkflowCanvas"; import { useCanvasPreviewMode } from "@/components/CanvasPreviewModeContext"; +import { useI18n } from "@/i18n"; +import { isPopiProviderMode } from "@/lib/providerMode"; +import type { NodeType, WorkflowNode, WorkflowNodeData } from "@/types"; +import { useInlineParameters } from "@/hooks/useInlineParameters"; +import { browseRegistry } from "@/utils/browseRegistry"; +import { isSmartAudioGenerateMode } from "@/utils/smartAudioMode"; +import { isSmartImageGenerateMode } from "@/utils/smartImageMode"; +import { isSmartVideoGenerateMode } from "@/utils/smartVideoMode"; +import { FloatingNodeHeader } from "./FloatingNodeHeader"; +import { useNodeHeaderContext } from "./NodeHeaderContext"; +import { getNodeHeaderMediaMeta } from "./nodeHeaderMedia"; +import { getEffectiveNodeHeaderModel, getNodeHeaderTitle } from "./nodeHeaderTitle"; const DEFAULT_NODE_DIMENSION = 300; @@ -72,9 +84,15 @@ export function BaseNode({ floatingPanel, dataTutorial, }: BaseNodeProps) { + const { t } = useI18n(); const isPreviewMode = useCanvasPreviewMode(); const currentNodeIds = useWorkflowStore((state) => state.currentNodeIds); const setHoveredNodeId = useWorkflowStore((state) => state.setHoveredNodeId); + const node = useWorkflowStore((state) => state.nodes.find((candidate) => candidate.id === id) as WorkflowNode | undefined); + const edges = useWorkflowStore((state) => state.edges); + const updateNodeData = useWorkflowStore((state) => state.updateNodeData); + const { onExpandNode } = useNodeHeaderContext(); + const { inlineParametersEnabled } = useInlineParameters(); const isCurrentlyExecuting = currentNodeIds.includes(id); const { setNodes } = useReactFlow(); @@ -235,6 +253,44 @@ export function BaseNode({ }, []); const hasExpandedSettings = settingsExpanded && settingsPanel; + const nodeTitle = node ? getNodeHeaderTitle(node, edges, t) : ""; + const mediaMeta = node ? getNodeHeaderMediaMeta(node, nodeTitle, edges) : null; + const customTitle = typeof node?.data?.customTitle === "string" ? node.data.customTitle : undefined; + const effectiveModel = node ? getEffectiveNodeHeaderModel(node, edges) : undefined; + const nodeData = node?.data as WorkflowNodeData | undefined; + const showBrowse = Boolean(node && inlineParametersEnabled && ( + (node.type === "smartImage" && isSmartImageGenerateMode(node.id, nodeData, edges)) || + (node.type === "smartAudio" && isSmartAudioGenerateMode(node.id, nodeData, edges)) || + (node.type === "smartVideo" && isSmartVideoGenerateMode(node.id, nodeData, edges)) || + node.type === "nanoBanana" || + node.type === "generateVideo" || + node.type === "generate3d" || + node.type === "generateAudio" + )); + const browseAction = showBrowse ? ( + + ) : undefined; + const showHeader = Boolean(node && node.type !== "group" && !isPreviewMode); + const header = showHeader ? ( + )?.isInLockedGroup)} + selected={selected} + title={nodeTitle} + mediaMeta={mediaMeta} + customTitle={customTitle} + provider={isPopiProviderMode() ? undefined : effectiveModel?.provider} + headerAction={browseAction} + onCustomTitleChange={(nodeId, title) => updateNodeData(nodeId, { customTitle: title || undefined })} + onExpandNode={onExpandNode} + /> + ) : null; return (
+ {header}
{children}
{floatingPanel}
diff --git a/src/components/nodes/FloatingNodeHeader.tsx b/src/components/nodes/FloatingNodeHeader.tsx index af50c966..a13ae817 100644 --- a/src/components/nodes/FloatingNodeHeader.tsx +++ b/src/components/nodes/FloatingNodeHeader.tsx @@ -57,8 +57,6 @@ interface FloatingNodeHeaderProps { id: string; type: NodeType; isInLockedGroup?: boolean; - position: { x: number; y: number }; - width: number; selected: boolean; onExpandNode?: (nodeId: string, nodeType: string) => void; headerAction?: ReactNode; @@ -79,8 +77,6 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({ id, type, isInLockedGroup = false, - position, - width, selected, onExpandNode, headerAction, @@ -154,6 +150,7 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({ // Drag-to-move: allow repositioning nodes by dragging the header const { setNodes, getNodes, getViewport } = useReactFlow(); const isDraggingRef = useRef(false); + const [isHeaderDragging, setIsHeaderDragging] = useState(false); const handleHeaderPointerDown = useCallback((e: React.PointerEvent) => { // Don't drag from interactive elements @@ -194,6 +191,7 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({ if (!isDraggingRef.current && (Math.abs(screenDx) > 5 || Math.abs(screenDy) > 5)) { isDraggingRef.current = true; + setIsHeaderDragging(true); } if (isDraggingRef.current) { @@ -217,6 +215,7 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({ document.removeEventListener('pointermove', handlePointerMove); document.removeEventListener('pointerup', handlePointerUp); isDraggingRef.current = false; + setIsHeaderDragging(false); // Check group membership for ALL moved nodes if (wasDragging) { @@ -291,12 +290,8 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({ return (
void; +} + +const NodeHeaderContext = createContext({}); + +export const NodeHeaderProvider = NodeHeaderContext.Provider; + +export function useNodeHeaderContext(): NodeHeaderContextValue { + return useContext(NodeHeaderContext); +} diff --git a/src/components/nodes/nodeHeaderTitle.ts b/src/components/nodes/nodeHeaderTitle.ts new file mode 100644 index 00000000..a9584e9c --- /dev/null +++ b/src/components/nodes/nodeHeaderTitle.ts @@ -0,0 +1,92 @@ +import type { Node } from "@xyflow/react"; +import type { NodeType, SelectedModel, WorkflowEdge, WorkflowNodeData } from "@/types"; +import type { TranslationKey } from "@/i18n"; +import { isSmartAudioGenerateMode } from "@/utils/smartAudioMode"; +import { isSmartImageGenerateMode } from "@/utils/smartImageMode"; +import { isSmartVideoGenerateMode } from "@/utils/smartVideoMode"; + +type Translate = (key: TranslationKey, values?: Record) => string; + +const NODE_TITLE_KEYS: Record = { + group: "canvasOutline.group", + imageInput: "node.imageInput", + smartImage: "node.image", + smartVideo: "node.generateVideo", + smartAudio: "toolbar.audio", + audioInput: "node.audioInput", + videoInput: "node.videoInput", + annotation: "node.annotation", + prompt: "node.prompt", + smartText: "smartText.title", + array: "node.array", + promptConstructor: "node.promptConstructor", + nanoBanana: "node.generateImage", + generateVideo: "node.generateVideo", + generate3d: "node.generate3d", + generateAudio: "node.generateAudio", + llmGenerate: "node.llmGenerate", + output: "node.output", + outputGallery: "node.outputGallery", + imageCompare: "node.imageCompare", + videoStitch: "node.videoStitch", + easeCurve: "node.easeCurve", + videoTrim: "node.videoTrim", + videoFrameGrab: "node.frameGrab", + router: "node.router", + switch: "node.switch", + conditionalSwitch: "node.conditionalSwitch", + glbViewer: "node.glbViewer", +}; + +export function getEffectiveNodeHeaderModel(node: Node, edges: WorkflowEdge[]): SelectedModel | undefined { + const data = node.data as WorkflowNodeData & { + outputImage?: unknown; + outputAudio?: unknown; + outputVideo?: unknown; + lastUsedModel?: SelectedModel; + selectedModel?: SelectedModel; + }; + const smartImageGenerate = node.type === "smartImage" && isSmartImageGenerateMode(node.id, data, edges); + const smartAudioGenerate = node.type === "smartAudio" && isSmartAudioGenerateMode(node.id, data, edges); + const smartVideoGenerate = node.type === "smartVideo" && isSmartVideoGenerateMode(node.id, data, edges); + const hasOutput = smartImageGenerate || node.type === "nanoBanana" + ? data.outputImage + : smartAudioGenerate || node.type === "generateAudio" + ? data.outputAudio + : data.outputVideo; + + if ((smartImageGenerate || smartVideoGenerate || node.type === "nanoBanana" || node.type === "generateVideo") && hasOutput && data.lastUsedModel) { + return data.lastUsedModel; + } + + return data.selectedModel; +} + +export function getNodeHeaderTitle(node: Node, edges: WorkflowEdge[], t: Translate): string { + const data = node.data as WorkflowNodeData & { + selectedLLMModel?: SelectedModel & { name?: string }; + selectedModel?: SelectedModel & { name?: string }; + }; + const smartImageGenerate = node.type === "smartImage" && isSmartImageGenerateMode(node.id, data, edges); + const smartAudioGenerate = node.type === "smartAudio" && isSmartAudioGenerateMode(node.id, data, edges); + const smartVideoGenerate = node.type === "smartVideo" && isSmartVideoGenerateMode(node.id, data, edges); + + if (smartImageGenerate || smartVideoGenerate || node.type === "nanoBanana" || node.type === "generateVideo") { + const model = getEffectiveNodeHeaderModel(node, edges); + if (model?.displayName) return model.displayName; + } + + if (smartAudioGenerate || node.type === "generate3d" || node.type === "generateAudio") { + const model = data.selectedModel; + if (model?.displayName) return model.displayName; + } + + if (node.type === "llmGenerate" || node.type === "smartText") { + const model = data.selectedLLMModel || data.selectedModel; + if (model?.displayName) return model.displayName; + if (model?.name) return model.name; + } + + const key = NODE_TITLE_KEYS[node.type as NodeType]; + return key ? t(key) : t("node.generic"); +}