From 31df53cecc8ea2142199691056ea812b78fdbdd3 Mon Sep 17 00:00:00 2001 From: Luckyu_js <11670186+luckyu-js@user.noreply.gitee.com> Date: Mon, 20 Jul 2026 11:27:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=8C=E5=87=BB=E4=BA=8B=E4=BB=B6=E4=BF=AE?= =?UTF-8?q?=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/WorkflowCanvas.tsx | 63 ++++++++-- src/components/__tests__/BaseNode.test.tsx | 109 +----------------- src/components/nodes/BaseNode.tsx | 52 --------- .../nodes/NodeDoubleClickZoomContext.tsx | 27 ----- 4 files changed, 55 insertions(+), 196 deletions(-) delete mode 100644 src/components/nodes/NodeDoubleClickZoomContext.tsx diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index e56de838..9581968a 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -33,7 +33,6 @@ import { workflowEdgeTypes, workflowNodeTypes } from "./workflowTypes"; import { ConnectionDropMenu, type ConnectionIntent } from "./ConnectionDropMenu"; import { MultiSelectToolbar } from "./MultiSelectToolbar"; import { EdgeToolbar } from "./EdgeToolbar"; -import { NodeDoubleClickZoomProvider } from "./nodes/NodeDoubleClickZoomContext"; import { NodeType, NanoBananaNodeData, HandleType, PromptNodeData, SmartTextNodeData, LLMGenerateNodeData, PromptConstructorNodeData, AvailableVariable, SelectedModel, WorkflowNode } from "@/types"; import { DEFAULT_NODE_DIMENSIONS, defaultNodeDimensions } from "@/store/utils/nodeDefaults"; import { getWorkflowNodeStyleDimensions } from "@/utils/nodeDimensions"; @@ -99,6 +98,7 @@ import { getNodeAcceptedConnectionGroups } from "@/utils/nodeConnectionRegistry" import { suppressBoxSelectionClick } from "@/utils/canvasInteractionGuards"; import { calculateCanvasAutoLayout } from "@/utils/canvasAutoLayout"; import { canConnectByNodeConnectionSpec } from "@/utils/nodeConnectionSpec"; +import { REQUEST_SMART_TEXT_EDIT_EVENT } from "@/utils/smartTextEditRequest"; const BOX_SELECTION_EXPAND_MIN_DISTANCE = 4; const MULTI_SELECTION_BOUNDS_PADDING = 40; @@ -131,8 +131,27 @@ const CANVAS_LOCAL_INTERACTION_SELECTOR = [ '[contenteditable="true"]', '[data-canvas-keyboard-scope="local"]', ].join(", "); +// Node double-click should still zoom even though React Flow marks every node +// wrapper with `.nopan`/`.nodrag`; only bail on genuinely interactive controls +// (and the header title editor, which owns its own double-click to rename). +const NODE_DOUBLE_CLICK_IGNORE_SELECTOR = [ + "button", + "input", + "textarea", + "select", + '[contenteditable="true"]', + "[data-node-title-editor-trigger]", +].join(", "); const NODE_DOUBLE_CLICK_ZOOM_DURATION = 260; +function shouldIgnoreCanvasGesture(target: EventTarget | null): boolean { + return target instanceof HTMLElement && Boolean(target.closest(CANVAS_LOCAL_INTERACTION_SELECTOR)); +} + +function shouldIgnoreNodeDoubleClick(target: EventTarget | null): boolean { + return target instanceof HTMLElement && Boolean(target.closest(NODE_DOUBLE_CLICK_IGNORE_SELECTOR)); +} + function isWorkflowNode(candidate: Node): candidate is WorkflowNode { return candidate.type !== "batchConnection"; } @@ -1172,7 +1191,7 @@ export function WorkflowCanvas() { const zoomNodeIntoFocusById = useCallback((nodeId: string) => { const currentNodes = useWorkflowStore.getState().nodes; const node = currentNodes.find((candidate) => candidate.id === nodeId) ?? nodes.find((candidate) => candidate.id === nodeId); - if (!node) return; + if (!node) return Promise.resolve(false); const { width, height } = getNodeDimensions(node); const nodePosition = absoluteNodePositions.get(node.id) ?? node.position; @@ -1195,23 +1214,48 @@ export function WorkflowCanvas() { window.getSelection()?.removeAllRanges(); selectSingleNode(node.id); setZoomPercent(Math.round(nextZoom * 100)); - setCenter(center.x, center.y, { + return Promise.resolve(setCenter(center.x, center.y, { duration: NODE_DOUBLE_CLICK_ZOOM_DURATION, zoom: nextZoom, - }); + })); }, [absoluteNodePositions, nodes, selectSingleNode, setCenter]); - const zoomNodeById = useCallback((nodeId: string) => { - zoomNodeIntoFocusById(nodeId); - }, [zoomNodeIntoFocusById]); + // Handle node double-click in the capture phase on the canvas wrapper, ahead of + // any node-internal double-click handlers (e.g. legacy inline editors). This lets + // a double-click focus/zoom the node without also triggering its own edit affordance; + // smart text opts back into editing explicitly via REQUEST_SMART_TEXT_EDIT_EVENT. + const handleCanvasDoubleClickCapture = useCallback((event: ReactMouseEvent) => { + const target = event.target as HTMLElement | null; + const nodeElement = target?.closest(".react-flow__node[data-id]"); + if (!nodeElement) return; // pane double-click is handled by handlePaneClick + if (shouldIgnoreNodeDoubleClick(target)) return; // let interactive controls / title rename run + + const nodeId = nodeElement.getAttribute("data-id"); + if (!nodeId) return; + + const node = + useWorkflowStore.getState().nodes.find((candidate) => candidate.id === nodeId) ?? + nodes.find((candidate) => candidate.id === nodeId); + if (node && !isWorkflowNode(node)) return; + + event.preventDefault(); + event.stopPropagation(); + + void zoomNodeIntoFocusById(nodeId).then(() => { + if (node?.type !== "smartText") return; + window.dispatchEvent(new CustomEvent(REQUEST_SMART_TEXT_EDIT_EVENT, { + detail: { nodeId }, + })); + }); + }, [nodes, zoomNodeIntoFocusById]); const handlePaneClick = useCallback((event: ReactMouseEvent) => { if (event.detail !== 2) return; const target = event.target as HTMLElement | null; - if (target?.closest(CANVAS_LOCAL_INTERACTION_SELECTOR)) return; + if (shouldIgnoreCanvasGesture(target)) return; event.preventDefault(); window.getSelection()?.removeAllRanges(); @@ -2963,6 +3007,7 @@ export function WorkflowCanvas() { }`} onMouseDownCapture={handleCanvasMouseDownCapture} onMouseMoveCapture={handleCanvasMouseMoveCapture} + onDoubleClickCapture={handleCanvasDoubleClickCapture} onMouseMove={updateLastCanvasPointerPosition} onDragOver={handleDragOver} onDragLeave={handleDragLeave} @@ -2991,7 +3036,6 @@ export function WorkflowCanvas() { - - diff --git a/src/components/__tests__/BaseNode.test.tsx b/src/components/__tests__/BaseNode.test.tsx index cf5eb635..2b3689c0 100644 --- a/src/components/__tests__/BaseNode.test.tsx +++ b/src/components/__tests__/BaseNode.test.tsx @@ -1,14 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { BaseNode } from "@/components/nodes/BaseNode"; -import { NodeDoubleClickZoomProvider } from "@/components/nodes/NodeDoubleClickZoomContext"; -import { REQUEST_SMART_TEXT_EDIT_EVENT } from "@/utils/smartTextEditRequest"; import { ReactFlowProvider } from "@xyflow/react"; // Mock the workflow store const mockSetHoveredNodeId = vi.fn(); const mockUseWorkflowStore = vi.fn(); -const mockZoomNodeById = vi.fn(); vi.mock("@/store/workflowStore", () => ({ useWorkflowStore: (selector: (state: unknown) => unknown) => mockUseWorkflowStore(selector), @@ -39,9 +36,7 @@ vi.mock("@xyflow/react", async () => { function TestWrapper({ children }: { children: React.ReactNode }) { return ( - - {children} - + {children} ); } @@ -207,106 +202,7 @@ describe("BaseNode", () => { }); }); - describe("Double-click zoom", () => { - it("should request node zoom when node content is double-clicked", () => { - render( - - - - ); - - fireEvent.doubleClick(screen.getByTestId("test-children")); - - expect(mockZoomNodeById).toHaveBeenCalledWith("test-node-1"); - }); - - it("should request node zoom and smart text editing when smart text content is double-clicked", () => { - vi.useFakeTimers(); - mockUseWorkflowStore.mockImplementation((selector) => { - const state = { - currentNodeIds: [] as string[], - edges: [], - updateNodeData: vi.fn(), - nodes: [{ id: "text-node-1", type: "smartText", data: {} }], - hoveredNodeId: null, - setHoveredNodeId: mockSetHoveredNodeId, - }; - return selector(state); - }); - const editListener = vi.fn(); - window.addEventListener(REQUEST_SMART_TEXT_EDIT_EVENT, editListener); - - try { - render( - - Text} /> - - ); - - fireEvent.doubleClick(screen.getByTestId("smart-text-content")); - - expect(mockZoomNodeById).toHaveBeenCalledWith("text-node-1"); - expect(editListener).not.toHaveBeenCalled(); - vi.advanceTimersByTime(80); - expect(editListener).toHaveBeenCalledTimes(1); - expect((editListener.mock.calls[0][0] as CustomEvent).detail).toEqual({ nodeId: "text-node-1" }); - } finally { - window.removeEventListener(REQUEST_SMART_TEXT_EDIT_EVENT, editListener); - vi.useRealTimers(); - } - }); - - it("should request node zoom from the second click when native dblclick is not emitted", () => { - render( - - - - ); - - fireEvent.click(screen.getByTestId("test-children"), { detail: 1 }); - fireEvent.click(screen.getByTestId("test-children"), { detail: 2 }); - - expect(mockZoomNodeById).toHaveBeenCalledWith("test-node-1"); - }); - - it("should request node zoom when node settings panel is double-clicked", () => { - render( - - Settings} - /> - - ); - - fireEvent.doubleClick(screen.getByTestId("settings-panel")); - - expect(mockZoomNodeById).toHaveBeenCalledWith("test-node-1"); - }); - - it("should request node zoom when local controls are double-clicked", () => { - render( - - - -
-
- )} - /> -
- ); - - fireEvent.doubleClick(screen.getByRole("button", { name: "Action" })); - fireEvent.doubleClick(screen.getByTestId("editable")); - - expect(mockZoomNodeById).toHaveBeenCalledTimes(2); - expect(mockZoomNodeById).toHaveBeenCalledWith("test-node-1"); - }); - + describe("Local editing", () => { it("should allow node title double-click editing to receive focus instead of zooming", async () => { render( @@ -317,7 +213,6 @@ describe("BaseNode", () => { fireEvent.doubleClick(screen.getByTitle("Double click to edit title")); const input = screen.getByPlaceholderText("Custom title...") as HTMLInputElement; - expect(mockZoomNodeById).not.toHaveBeenCalled(); expect(input).toHaveClass("border-[var(--border-strong)]"); await waitFor(() => expect(document.activeElement).toBe(input)); }); diff --git a/src/components/nodes/BaseNode.tsx b/src/components/nodes/BaseNode.tsx index 206ac07e..cf86fe42 100644 --- a/src/components/nodes/BaseNode.tsx +++ b/src/components/nodes/BaseNode.tsx @@ -1,7 +1,6 @@ "use client"; import { ReactNode, useMemo, useRef, useLayoutEffect } from "react"; -import type { MouseEvent as ReactMouseEvent } from "react"; import { Node, useReactFlow } from "@xyflow/react"; import { useWorkflowStore } from "@/store/workflowStore"; import { selectNodeById } from "@/store/utils/nodesById"; @@ -21,10 +20,8 @@ import { } from "@/utils/smartMediaMode"; import { FloatingNodeHeader } from "./FloatingNodeHeader"; import { useNodeHeaderContext } from "./NodeHeaderContext"; -import { useNodeDoubleClickZoom } from "./NodeDoubleClickZoomContext"; import { getNodeHeaderDimensionsText, getNodeHeaderIconKind } from "./nodeHeaderMedia"; import { getEffectiveNodeHeaderModel, getNodeHeaderTitle } from "./nodeHeaderTitle"; -import { REQUEST_SMART_TEXT_EDIT_EVENT } from "@/utils/smartTextEditRequest"; const DEFAULT_NODE_DIMENSION = 300; @@ -98,7 +95,6 @@ export function BaseNode({ const edges = useWorkflowStore((state) => state.edges); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const { onExpandNode } = useNodeHeaderContext(); - const nodeDoubleClickZoom = useNodeDoubleClickZoom(); const { inlineParametersEnabled } = useInlineParameters(); const isCurrentlyExecuting = currentNodeIds.includes(id); const { setNodes } = useReactFlow(); @@ -108,7 +104,6 @@ export function BaseNode({ const trackedSettingsHeightRef = useRef(0); const isAnimatingRef = useRef(false); const animationTimeoutRef = useRef | null>(null); - const lastNodeDoubleClickZoomRef = useRef<{ at: number; source: "click-detail" | "dblclick" } | null>(null); // Adjust node height when settings expand or collapse useLayoutEffect(() => { @@ -307,59 +302,12 @@ export function BaseNode({ /> ) : null; - const requestNodeDoubleClickAction = ( - event: ReactMouseEvent, - source: "click-detail" | "dblclick" - ) => { - const now = Date.now(); - const target = event.target as HTMLElement | null; - - if (isPreviewMode) { - return; - } - - if (target?.closest("[data-node-title-editor-trigger]")) { - return; - } - - const lastZoom = lastNodeDoubleClickZoomRef.current; - if (lastZoom && lastZoom.source !== source && now - lastZoom.at < 250) { - return; - } - lastNodeDoubleClickZoomRef.current = { at: now, source }; - - event.preventDefault(); - event.stopPropagation(); - - nodeDoubleClickZoom?.zoomNodeById(id); - - if (node?.type === "smartText") { - window.setTimeout(() => { - window.dispatchEvent(new CustomEvent(REQUEST_SMART_TEXT_EDIT_EVENT, { - detail: { nodeId: id }, - })); - }, 80); - return; - } - }; - - const handleNodeClickCapture = (event: ReactMouseEvent) => { - if (event.detail !== 2) return; - requestNodeDoubleClickAction(event, "click-detail"); - }; - - const handleNodeDoubleClickCapture = (event: ReactMouseEvent) => { - requestNodeDoubleClickAction(event, "dblclick"); - }; - return (
void; -} - -const NodeDoubleClickZoomContext = createContext(null); - -export function NodeDoubleClickZoomProvider({ - value, - children, -}: { - value: NodeDoubleClickZoomContextValue; - children: ReactNode; -}) { - return ( - - {children} - - ); -} - -export function useNodeDoubleClickZoom() { - return useContext(NodeDoubleClickZoomContext); -}