|
|
|
@ -37,9 +37,8 @@ import { GlobalImageHistory } from "./GlobalImageHistory"; |
|
|
|
import { NodeGenerationComposer } from "./composer/NodeGenerationComposer"; |
|
|
|
import { NodeType, NanoBananaNodeData, HandleType, PromptNodeData, SmartTextNodeData, LLMGenerateNodeData, PromptConstructorNodeData, AvailableVariable, SelectedModel, WorkflowNode } from "@/types"; |
|
|
|
import { DEFAULT_NODE_DIMENSIONS, defaultNodeDimensions } from "@/store/utils/nodeDefaults"; |
|
|
|
import { FloatingNodeHeader } from "./nodes/FloatingNodeHeader"; |
|
|
|
import { NodeHeaderProvider } from "./nodes/NodeHeaderContext"; |
|
|
|
import { NodeHandleMenuProvider, type NodeHandleMenuRequest } from "./nodes/NodeHandleMenuContext"; |
|
|
|
import { getNodeHeaderMediaMeta } from "./nodes/nodeHeaderMedia"; |
|
|
|
import { ControlPanel } from "./nodes/ControlPanel"; |
|
|
|
import { logger } from "@/utils/logger"; |
|
|
|
import { uploadFileToGatewayMedia } from "@/utils/gatewayMediaUpload"; |
|
|
|
@ -61,8 +60,6 @@ import { parseVarTags } from "@/utils/parseVarTags"; |
|
|
|
import { AnnotationModal } from "./AnnotationModal"; |
|
|
|
import { GenerateNodeMenu } from "./GenerateNodeMenu"; |
|
|
|
import { type CanvasContextMenuState } from "./CanvasContextMenu"; |
|
|
|
import { browseRegistry } from "@/utils/browseRegistry"; |
|
|
|
import { useInlineParameters } from "@/hooks/useInlineParameters"; |
|
|
|
import { useCanvasContextMenuActions } from "@/hooks/useCanvasContextMenuActions"; |
|
|
|
import { |
|
|
|
BATCH_SELECTION_NODE_ID, |
|
|
|
@ -76,12 +73,9 @@ import { useAnnotationStore } from "@/store/annotationStore"; |
|
|
|
import { TutorialOverlay } from "./onboarding/TutorialOverlay"; |
|
|
|
import { useFTUXStore } from "@/store/ftuxStore"; |
|
|
|
import { useI18n } from "@/i18n"; |
|
|
|
import { isPopiProviderMode } from "@/lib/providerMode"; |
|
|
|
import { buildDefaultGenerationNodeCreation } from "@/utils/defaultGenerationNodeModel"; |
|
|
|
import { isSmartImageGenerateMode } from "@/utils/smartImageMode"; |
|
|
|
import { isSmartAudioGenerateMode } from "@/utils/smartAudioMode"; |
|
|
|
import { deriveSmartTextMode, isSmartTextLlmMode } from "@/utils/smartTextMode"; |
|
|
|
import { isSmartVideoGenerateMode } from "@/utils/smartVideoMode"; |
|
|
|
import { deriveSmartTextMode } from "@/utils/smartTextMode"; |
|
|
|
import { useCanvasPreviewMode } from "@/components/CanvasPreviewModeContext"; |
|
|
|
import { getNodePrimaryImageUrl, getNodePrimaryMediaUrl, isTextOnlyContextMenuNode } from "@/utils/canvasNodeMedia"; |
|
|
|
import { |
|
|
|
@ -102,6 +96,12 @@ import { calculateCanvasAutoLayout } from "@/utils/canvasAutoLayout"; |
|
|
|
|
|
|
|
const BOX_SELECTION_EXPAND_MIN_DISTANCE = 4; |
|
|
|
const MULTI_SELECTION_BOUNDS_PADDING = 40; |
|
|
|
const GROUP_NODE_Z_INDEX = 0; |
|
|
|
const DEFAULT_NODE_Z_INDEX = 1; |
|
|
|
const SELECTED_NODE_Z_INDEX = 10000; |
|
|
|
const DRAGGING_GROUP_Z_INDEX = 9999; |
|
|
|
const DRAGGING_GROUP_MEMBER_Z_INDEX = 10001; |
|
|
|
const DRAGGING_NODE_Z_INDEX = 10002; |
|
|
|
|
|
|
|
type ScreenPoint = { |
|
|
|
x: number; |
|
|
|
@ -529,7 +529,6 @@ export function WorkflowCanvas() { |
|
|
|
const isPreviewMode = useCanvasPreviewMode(); |
|
|
|
const searchParams = useSearchParams(); |
|
|
|
const urlWorkflowId = searchParams?.get("workflowId") ?? null; |
|
|
|
const popiProviderMode = isPopiProviderMode(); |
|
|
|
const { nodes, edges, groups, isModalOpen, navigationTarget, canvasNavigationSettings, dimmedNodeIds, skippedNodeIds, providerSettings, isRunning } = |
|
|
|
useWorkflowStore(useShallow((state) => ({ |
|
|
|
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<string | null>(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<Record<string, string>>(() => ({ |
|
|
|
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() { |
|
|
|
)} |
|
|
|
|
|
|
|
<NodeHandleMenuProvider value={nodeHandleMenuValue}> |
|
|
|
<ReactFlow |
|
|
|
nodes={reactFlowNodes} |
|
|
|
edges={edges} |
|
|
|
onNodesChange={handleReactFlowNodesChange} |
|
|
|
onEdgesChange={onEdgesChange} |
|
|
|
onConnect={handleConnect} |
|
|
|
onConnectEnd={handleConnectEnd} |
|
|
|
onMoveStart={() => { 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, |
|
|
|
}} |
|
|
|
> |
|
|
|
<SharedEdgeGradients /> |
|
|
|
<Background |
|
|
|
color={canvasGridColor} |
|
|
|
gap={20} |
|
|
|
size={1} |
|
|
|
className={tutorialActive && lockedFeatures ? "opacity-30 pointer-events-none" : ""} |
|
|
|
/> |
|
|
|
{isMiniMapOpen && ( |
|
|
|
<MiniMap |
|
|
|
position="bottom-left" |
|
|
|
className={`!rounded-none !border-0 !bg-[var(--surface-2)] !shadow-[var(--shadow-panel)] [&_.react-flow__minimap-mask]:stroke-transparent ${tutorialActive && lockedFeatures ? "opacity-30 pointer-events-none" : ""}`} |
|
|
|
style={{ width: 190, height: 112, bottom: 58, left: 22 }} |
|
|
|
maskColor={miniMapMaskColor} |
|
|
|
pannable |
|
|
|
zoomable |
|
|
|
nodeColor={miniMapNodeColor} |
|
|
|
/> |
|
|
|
)} |
|
|
|
<ViewportPortal> |
|
|
|
{boxSelectionRect && ( |
|
|
|
<div |
|
|
|
className="pointer-events-none absolute z-[7000] rounded-lg border border-dashed border-[var(--text-muted)] bg-[var(--surface-overlay)]/35 shadow-[inset_0_0_0_1px_var(--border-strong)]" |
|
|
|
style={{ |
|
|
|
left: `${boxSelectionRect.x}px`, |
|
|
|
top: `${boxSelectionRect.y}px`, |
|
|
|
width: `${boxSelectionRect.width}px`, |
|
|
|
height: `${boxSelectionRect.height}px`, |
|
|
|
}} |
|
|
|
/> |
|
|
|
)} |
|
|
|
{!boxSelectionRect && multiSelectionBounds && ( |
|
|
|
<div |
|
|
|
className="pointer-events-none absolute z-[7000] rounded-lg border border-dashed border-[var(--text-muted)] bg-[var(--surface-overlay)]/35 shadow-[inset_0_0_0_1px_var(--border-strong)]" |
|
|
|
style={{ |
|
|
|
left: `${multiSelectionBounds.x}px`, |
|
|
|
top: `${multiSelectionBounds.y}px`, |
|
|
|
width: `${multiSelectionBounds.width}px`, |
|
|
|
height: `${multiSelectionBounds.height}px`, |
|
|
|
}} |
|
|
|
<NodeHeaderProvider value={{ onExpandNode: handleExpandNode }}> |
|
|
|
<ReactFlow |
|
|
|
nodes={reactFlowNodes} |
|
|
|
edges={edges} |
|
|
|
onNodesChange={handleReactFlowNodesChange} |
|
|
|
onEdgesChange={onEdgesChange} |
|
|
|
onConnect={handleConnect} |
|
|
|
onConnectEnd={handleConnectEnd} |
|
|
|
onMoveStart={() => { 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, |
|
|
|
}} |
|
|
|
> |
|
|
|
<SharedEdgeGradients /> |
|
|
|
<Background |
|
|
|
color={canvasGridColor} |
|
|
|
gap={20} |
|
|
|
size={1} |
|
|
|
className={tutorialActive && lockedFeatures ? "opacity-30 pointer-events-none" : ""} |
|
|
|
/> |
|
|
|
)} |
|
|
|
{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 ? ( |
|
|
|
<button |
|
|
|
onClick={() => browseRegistry.open(node.id)} |
|
|
|
className="nodrag nopan text-[10px] py-0.5 px-1.5 bg-neutral-700 hover:bg-neutral-600 border border-neutral-600 rounded text-neutral-300 transition-colors" |
|
|
|
> |
|
|
|
{t("common.browse")} |
|
|
|
</button> |
|
|
|
) : 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 ( |
|
|
|
<FloatingNodeHeader |
|
|
|
key={`header-${node.id}`} |
|
|
|
id={node.id} |
|
|
|
type={node.type as NodeType} |
|
|
|
isInLockedGroup={!!(node.data as any)?.isInLockedGroup} |
|
|
|
position={absoluteNodePositions.get(node.id) ?? node.position} |
|
|
|
width={headerWidth} |
|
|
|
selected={isSingleCanvasSelection && !!node.selected} |
|
|
|
title={nodeTitle} |
|
|
|
mediaMeta={mediaMeta} |
|
|
|
customTitle={customTitle} |
|
|
|
provider={popiProviderMode ? undefined : effectiveModel?.provider} |
|
|
|
headerAction={browseAction} |
|
|
|
onCustomTitleChange={handleCustomTitleChange} |
|
|
|
onExpandNode={handleExpandNode} |
|
|
|
{isMiniMapOpen && ( |
|
|
|
<MiniMap |
|
|
|
position="bottom-left" |
|
|
|
className={`!rounded-none !border-0 !bg-[var(--surface-2)] !shadow-[var(--shadow-panel)] [&_.react-flow__minimap-mask]:stroke-transparent ${tutorialActive && lockedFeatures ? "opacity-30 pointer-events-none" : ""}`} |
|
|
|
style={{ width: 190, height: 112, bottom: 58, left: 22 }} |
|
|
|
maskColor={miniMapMaskColor} |
|
|
|
pannable |
|
|
|
zoomable |
|
|
|
nodeColor={miniMapNodeColor} |
|
|
|
/> |
|
|
|
); |
|
|
|
})} |
|
|
|
{globalComposerTarget ? ( |
|
|
|
<NodeGenerationComposer |
|
|
|
nodeId={globalComposerTarget.nodeId} |
|
|
|
selected={globalComposerTarget.selected} |
|
|
|
position={globalComposerTarget.position} |
|
|
|
/> |
|
|
|
) : null} |
|
|
|
</ViewportPortal> |
|
|
|
</ReactFlow> |
|
|
|
)} |
|
|
|
<ViewportPortal> |
|
|
|
{boxSelectionRect && ( |
|
|
|
<div |
|
|
|
className="pointer-events-none absolute z-[7000] rounded-lg border border-dashed border-[var(--text-muted)] bg-[var(--surface-overlay)]/35 shadow-[inset_0_0_0_1px_var(--border-strong)]" |
|
|
|
style={{ |
|
|
|
left: `${boxSelectionRect.x}px`, |
|
|
|
top: `${boxSelectionRect.y}px`, |
|
|
|
width: `${boxSelectionRect.width}px`, |
|
|
|
height: `${boxSelectionRect.height}px`, |
|
|
|
}} |
|
|
|
/> |
|
|
|
)} |
|
|
|
{!boxSelectionRect && multiSelectionBounds && ( |
|
|
|
<div |
|
|
|
className="pointer-events-none absolute z-[7000] rounded-lg border border-dashed border-[var(--text-muted)] bg-[var(--surface-overlay)]/35 shadow-[inset_0_0_0_1px_var(--border-strong)]" |
|
|
|
style={{ |
|
|
|
left: `${multiSelectionBounds.x}px`, |
|
|
|
top: `${multiSelectionBounds.y}px`, |
|
|
|
width: `${multiSelectionBounds.width}px`, |
|
|
|
height: `${multiSelectionBounds.height}px`, |
|
|
|
}} |
|
|
|
/> |
|
|
|
)} |
|
|
|
{globalComposerTarget ? ( |
|
|
|
<NodeGenerationComposer |
|
|
|
nodeId={globalComposerTarget.nodeId} |
|
|
|
selected={globalComposerTarget.selected} |
|
|
|
position={globalComposerTarget.position} |
|
|
|
/> |
|
|
|
) : null} |
|
|
|
</ViewportPortal> |
|
|
|
</ReactFlow> |
|
|
|
</NodeHeaderProvider> |
|
|
|
</NodeHandleMenuProvider> |
|
|
|
|
|
|
|
<div |
|
|
|
|