Browse Source

节点层级处理

feature/071
yun 2 weeks ago
parent
commit
0fd8084e78
  1. 6
      src/app/globals.css
  2. 2
      src/components/MultiAngleEditor.tsx
  3. 388
      src/components/WorkflowCanvas.tsx
  4. 10
      src/components/__tests__/FloatingNodeHeader.test.tsx
  5. 2
      src/components/__tests__/GroupNode.test.tsx
  6. 22
      src/components/media/MediaCropOverlay.tsx
  7. 2
      src/components/media/MediaEditToolbar.tsx
  8. 4
      src/components/media/SplitGridCapsuleMenu.tsx
  9. 6
      src/components/media/SplitGridSelectionOverlay.tsx
  10. 2
      src/components/media/SplitGridSelectionToolbar.tsx
  11. 57
      src/components/nodes/BaseNode.tsx
  12. 15
      src/components/nodes/FloatingNodeHeader.tsx
  13. 15
      src/components/nodes/NodeHeaderContext.tsx
  14. 92
      src/components/nodes/nodeHeaderTitle.ts

6
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

2
src/components/MultiAngleEditor.tsx

@ -360,7 +360,7 @@ export function MultiAngleEditor({
transform: `scale(${previewScale}) rotateX(${cubeRotateX}deg) rotateY(${cubeRotateY}deg)`,
}}
>
<div className={`${cubeFaceClass} overflow-hidden border-[var(--accent)]/70 bg-[var(--surface-2)]/55 shadow-[0_18px_42px_rgba(0,0,0,0.42)] [transform:translateZ(60px)]`}>
<div className={`${cubeFaceClass} overflow-hidden border-[var(--accent)]/70 bg-[var(--surface-2)]/55 shadow-[var(--shadow-panel)] [transform:translateZ(60px)]`}>
{previewImage ? (
<img
src={previewImage}

388
src/components/WorkflowCanvas.tsx

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

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

@ -22,8 +22,6 @@ describe("FloatingNodeHeader", () => {
<FloatingNodeHeader
id="image-1"
type={"imageInput" as NodeType}
position={{ x: 120, y: 220 }}
width={320}
selected={false}
title="Image Input"
mediaMeta={{
@ -43,8 +41,6 @@ describe("FloatingNodeHeader", () => {
<FloatingNodeHeader
id="image-1"
type={"imageInput" as NodeType}
position={{ x: 120, y: 220 }}
width={320}
selected={false}
title="Image Input"
/>
@ -58,8 +54,6 @@ describe("FloatingNodeHeader", () => {
<FloatingNodeHeader
id="image-1"
type={"nanoBanana" as NodeType}
position={{ x: 120, y: 220 }}
width={320}
selected
title="Nano Banana"
mediaMeta={{
@ -84,8 +78,6 @@ describe("FloatingNodeHeader", () => {
<FloatingNodeHeader
id="llm-1"
type={"llmGenerate" as NodeType}
position={{ x: 120, y: 220 }}
width={320}
selected
title="LLM"
onExpandNode={onExpandNode}
@ -101,8 +93,6 @@ describe("FloatingNodeHeader", () => {
<FloatingNodeHeader
id="text-1"
type={"smartText" as NodeType}
position={{ x: 120, y: 220 }}
width={320}
selected
title="Text"
/>

2
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<string>(),
});
});

22
src/components/media/MediaCropOverlay.tsx

@ -385,21 +385,21 @@ export function MediaCropOverlay({
>
{bounds && crop && (
<>
<div className="absolute bg-black/45" style={{ left: bounds.left, top: bounds.top, width: bounds.width, height: crop.y - bounds.top }} />
<div className="absolute bg-black/45" style={{ left: bounds.left, top: crop.y + crop.height, width: bounds.width, height: bounds.top + bounds.height - crop.y - crop.height }} />
<div className="absolute bg-black/45" style={{ left: bounds.left, top: crop.y, width: crop.x - bounds.left, height: crop.height }} />
<div className="absolute bg-black/45" style={{ left: crop.x + crop.width, top: crop.y, width: bounds.left + bounds.width - crop.x - crop.width, height: crop.height }} />
<div className="absolute bg-[var(--surface-overlay)]" style={{ left: bounds.left, top: bounds.top, width: bounds.width, height: crop.y - bounds.top }} />
<div className="absolute bg-[var(--surface-overlay)]" style={{ left: bounds.left, top: crop.y + crop.height, width: bounds.width, height: bounds.top + bounds.height - crop.y - crop.height }} />
<div className="absolute bg-[var(--surface-overlay)]" style={{ left: bounds.left, top: crop.y, width: crop.x - bounds.left, height: crop.height }} />
<div className="absolute bg-[var(--surface-overlay)]" style={{ left: crop.x + crop.width, top: crop.y, width: bounds.left + bounds.width - crop.x - crop.width, height: crop.height }} />
<div
role="presentation"
className="absolute cursor-move border-[3px] border-white shadow-[0_0_0_1px_rgba(0,0,0,0.24)]"
className="absolute cursor-move border-[3px] border-[var(--accent)] shadow-[0_0_0_1px_var(--accent-ring)]"
style={{ left: crop.x, top: crop.y, width: crop.width, height: crop.height }}
onPointerDown={(event) => handlePointerDown(event, "move")}
>
<div className="pointer-events-none absolute left-1/3 top-0 h-full border-l border-white/55" />
<div className="pointer-events-none absolute left-2/3 top-0 h-full border-l border-white/55" />
<div className="pointer-events-none absolute left-0 top-1/3 w-full border-t border-white/55" />
<div className="pointer-events-none absolute left-0 top-2/3 w-full border-t border-white/55" />
<div className="pointer-events-none absolute left-1/3 top-0 h-full border-l border-[var(--accent-ring)]" />
<div className="pointer-events-none absolute left-2/3 top-0 h-full border-l border-[var(--accent-ring)]" />
<div className="pointer-events-none absolute left-0 top-1/3 w-full border-t border-[var(--accent-ring)]" />
<div className="pointer-events-none absolute left-0 top-2/3 w-full border-t border-[var(--accent-ring)]" />
{(["nw", "ne", "sw", "se"] as const).map((mode) => (
<button
key={mode}
@ -415,7 +415,7 @@ export function MediaCropOverlay({
>
<span
className={[
"pointer-events-none absolute h-4 w-4 border-white",
"pointer-events-none absolute h-4 w-4 border-[var(--accent)]",
mode.includes("n") ? "top-3.5 border-t-[3px]" : "bottom-3.5 border-b-[3px]",
mode.includes("w") ? "left-3.5 border-l-[3px]" : "right-3.5 border-r-[3px]",
].join(" ")}
@ -438,7 +438,7 @@ export function MediaCropOverlay({
>
<span
className={[
"pointer-events-none absolute bg-white",
"pointer-events-none absolute bg-[var(--accent)]",
mode === "n" ? "left-1/2 top-3.5 h-[3px] w-9 -translate-x-1/2" : "",
mode === "s" ? "bottom-3.5 left-1/2 h-[3px] w-9 -translate-x-1/2" : "",
mode === "e" ? "right-3.5 top-1/2 h-9 w-[3px] -translate-y-1/2" : "",

2
src/components/media/MediaEditToolbar.tsx

@ -83,7 +83,7 @@ export function CropRatioSelect({
aria-label="确认裁剪"
disabled={busy}
onClick={onConfirm}
className="flex h-9 min-w-[56px] shrink-0 items-center justify-center whitespace-nowrap rounded-lg bg-white px-4 text-sm font-semibold text-black hover:bg-neutral-200 disabled:cursor-wait disabled:opacity-70"
className="flex h-9 min-w-[56px] shrink-0 items-center justify-center whitespace-nowrap rounded-lg bg-[var(--accent)] px-4 text-sm font-semibold text-[var(--text-on-overlay)] hover:bg-[var(--accent-hover)] disabled:cursor-wait disabled:opacity-70"
>
{busy ? "..." : "确认"}
</button>

4
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(" ")}
/>
);

6
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 ? (
<span className="absolute left-1/2 top-1/2 rounded bg-black/65 px-1.5 py-0.5 text-xs font-semibold text-white -translate-x-1/2 -translate-y-1/2">
<span className="absolute left-1/2 top-1/2 rounded bg-[var(--surface-overlay)] px-1.5 py-0.5 text-xs font-semibold text-[var(--text-on-overlay)] -translate-x-1/2 -translate-y-1/2">
{cellKey}
</span>
) : null}

2
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 ? "切割中..." : "确认切割"}
</button>

57
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 ? (
<button
onClick={() => browseRegistry.open(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 showHeader = Boolean(node && node.type !== "group" && !isPreviewMode);
const header = showHeader ? (
<FloatingNodeHeader
id={id}
type={node!.type as NodeType}
isInLockedGroup={Boolean((node!.data as Record<string, unknown>)?.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 (
<div
@ -268,6 +324,7 @@ export function BaseNode({
setHoveredNodeId(null);
}}
>
{header}
<div ref={contentRef} style={{ contain: "layout style" }} className={contentClassName ?? (fullBleed ? "flex-1 min-h-0 relative" : "px-3 pb-4 flex-1 min-h-0 overflow-visible flex flex-col")}>{children}</div>
{floatingPanel}
</div>

15
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 (
<div
className="absolute pointer-events-none transition-opacity duration-200"
style={{
left: `${position.x}px`,
top: `${position.y - 26}px`,
width: `${width}px`,
}}
className="absolute inset-x-0 -top-[26px] pointer-events-none transition-opacity duration-200"
data-node-header-dragging={isHeaderDragging ? "true" : undefined}
>
<div
className="relative px-1 py-1 flex items-center justify-between w-full pointer-events-auto cursor-grab"

15
src/components/nodes/NodeHeaderContext.tsx

@ -0,0 +1,15 @@
"use client";
import { createContext, useContext } from "react";
export interface NodeHeaderContextValue {
onExpandNode?: (nodeId: string, nodeType: string) => void;
}
const NodeHeaderContext = createContext<NodeHeaderContextValue>({});
export const NodeHeaderProvider = NodeHeaderContext.Provider;
export function useNodeHeaderContext(): NodeHeaderContextValue {
return useContext(NodeHeaderContext);
}

92
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, string | number>) => string;
const NODE_TITLE_KEYS: Record<NodeType, TranslationKey> = {
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");
}
Loading…
Cancel
Save