Browse Source

双击事件修改

feature/create-task
Luckyu_js 5 days ago
parent
commit
31df53cecc
  1. 63
      src/components/WorkflowCanvas.tsx
  2. 109
      src/components/__tests__/BaseNode.test.tsx
  3. 52
      src/components/nodes/BaseNode.tsx
  4. 27
      src/components/nodes/NodeDoubleClickZoomContext.tsx

63
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<HTMLElement>(".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() {
<NodeHandleMenuProvider value={nodeHandleMenuValue}>
<NodeHeaderProvider value={{ onExpandNode: handleExpandNode }}>
<NodeDoubleClickZoomProvider value={{ zoomNodeById }}>
<ReactFlow
nodes={reactFlowNodes}
edges={reactFlowEdges}
@ -3106,7 +3150,6 @@ export function WorkflowCanvas() {
<NodeToolSelectionController />
</ViewportPortal>
</ReactFlow>
</NodeDoubleClickZoomProvider>
</NodeHeaderProvider>
</NodeHandleMenuProvider>

109
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 (
<ReactFlowProvider>
<NodeDoubleClickZoomProvider value={{ zoomNodeById: mockZoomNodeById }}>
{children}
</NodeDoubleClickZoomProvider>
{children}
</ReactFlowProvider>
);
}
@ -207,106 +202,7 @@ describe("BaseNode", () => {
});
});
describe("Double-click zoom", () => {
it("should request node zoom when node content is double-clicked", () => {
render(
<TestWrapper>
<BaseNode {...defaultProps} />
</TestWrapper>
);
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(
<TestWrapper>
<BaseNode id="text-node-1" children={<div data-testid="smart-text-content">Text</div>} />
</TestWrapper>
);
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(
<TestWrapper>
<BaseNode {...defaultProps} />
</TestWrapper>
);
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(
<TestWrapper>
<BaseNode
{...defaultProps}
settingsExpanded
settingsPanel={<div data-testid="settings-panel">Settings</div>}
/>
</TestWrapper>
);
fireEvent.doubleClick(screen.getByTestId("settings-panel"));
expect(mockZoomNodeById).toHaveBeenCalledWith("test-node-1");
});
it("should request node zoom when local controls are double-clicked", () => {
render(
<TestWrapper>
<BaseNode
{...defaultProps}
children={(
<div>
<button type="button">Action</button>
<div data-testid="editable" contentEditable suppressContentEditableWarning />
</div>
)}
/>
</TestWrapper>
);
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(
<TestWrapper>
@ -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));
});

52
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<ReturnType<typeof setTimeout> | 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<HTMLDivElement>,
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<HTMLDivElement>) => {
if (event.detail !== 2) return;
requestNodeDoubleClickAction(event, "click-detail");
};
const handleNodeDoubleClickCapture = (event: ReactMouseEvent<HTMLDivElement>) => {
requestNodeDoubleClickAction(event, "dblclick");
};
return (
<div
className={hasExpandedSettings
? `relative flex flex-col w-full h-full overflow-visible rounded-lg bg-[var(--surface-2)] text-[var(--text-primary)] shadow-[var(--shadow-node)] ${selected ? "ring-2 ring-[var(--node-selected-ring)] shadow-[var(--node-selected-shadow)]" : ""}`
: "contents"}
data-tutorial={hasExpandedSettings ? dataTutorial : undefined}
onClickCapture={handleNodeClickCapture}
onDoubleClickCapture={handleNodeDoubleClickCapture}
>
<div
className={`

27
src/components/nodes/NodeDoubleClickZoomContext.tsx

@ -1,27 +0,0 @@
"use client";
import { createContext, useContext, type ReactNode } from "react";
interface NodeDoubleClickZoomContextValue {
zoomNodeById: (nodeId: string) => void;
}
const NodeDoubleClickZoomContext = createContext<NodeDoubleClickZoomContextValue | null>(null);
export function NodeDoubleClickZoomProvider({
value,
children,
}: {
value: NodeDoubleClickZoomContextValue;
children: ReactNode;
}) {
return (
<NodeDoubleClickZoomContext.Provider value={value}>
{children}
</NodeDoubleClickZoomContext.Provider>
);
}
export function useNodeDoubleClickZoom() {
return useContext(NodeDoubleClickZoomContext);
}
Loading…
Cancel
Save