Browse Source

节点大小处理 点击画布失焦

feature/071
yun 1 week ago
parent
commit
0408404926
  1. 28
      src/components/Header.tsx
  2. 73
      src/components/__tests__/FloatingNodeHeader.test.tsx
  3. 2
      src/components/__tests__/GenerateImageNode.test.tsx
  4. 18
      src/components/__tests__/Header.test.tsx
  5. 34
      src/components/nodes/FloatingNodeHeader.tsx
  6. 14
      src/components/nodes/GenerateImageNode.tsx
  7. 28
      src/hooks/useCommitOnOutsidePointerDown.ts
  8. 17
      src/store/workflowStore.ts

28
src/components/Header.tsx

@ -1,6 +1,6 @@
"use client";
import { useState, useMemo, useCallback, useEffect, type ReactNode } from "react";
import { useState, useMemo, useCallback, useEffect, useRef, type ReactNode } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import {
@ -16,6 +16,7 @@ import { CostIndicator } from "./CostIndicator";
import { useI18n } from "@/i18n";
import { buildSerializedWorkflow } from "@/store/utils/workflowSerialization";
import { HeaderUserMenu } from "@/components/auth/HeaderUserMenu";
import { useCommitOnOutsidePointerDown } from "@/hooks/useCommitOnOutsidePointerDown";
const SHOW_UNUSED_HEADER_ACTIONS = false;
@ -123,6 +124,9 @@ export function Header() {
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [workflowNameDraft, setWorkflowNameDraft] = useState(workflowName || "");
const [isEditingWorkflowName, setIsEditingWorkflowName] = useState(false);
const workflowNameEditorRef = useRef<HTMLDivElement>(null);
const lastCommittedWorkflowNameDraftRef = useRef<string | null>(null);
const currentCanvasWorkflowDescription = useCanvasWorkflowStore((state) => state.currentCanvasWorkflowDescription);
const setCurrentCanvasWorkflow = useCanvasWorkflowStore((state) => state.setCurrentCanvasWorkflow);
@ -159,12 +163,20 @@ export function Header() {
const commitWorkflowName = useCallback(() => {
const nextName = workflowNameDraft.trim();
if (lastCommittedWorkflowNameDraftRef.current === nextName) {
setIsEditingWorkflowName(false);
return;
}
lastCommittedWorkflowNameDraftRef.current = nextName;
if (!nextName || nextName === workflowName) {
setWorkflowNameDraft(workflowName || "");
setIsEditingWorkflowName(false);
return;
}
setWorkflowName(nextName);
setIsEditingWorkflowName(false);
if (urlWorkflowId) {
setCurrentCanvasWorkflow({
title: nextName,
@ -180,6 +192,12 @@ export function Header() {
setCurrentCanvasWorkflow,
]);
useCommitOnOutsidePointerDown({
active: isEditingWorkflowName,
rootRef: workflowNameEditorRef,
onCommit: commitWorkflowName,
});
const handleRevertAIChanges = useCallback(() => {
const confirmed = window.confirm(
t("header.revertConfirm")
@ -205,7 +223,7 @@ export function Header() {
</Link>
<div className="flex min-w-0 items-center gap-2 pl-2">
<div className="grid shrink-0">
<div ref={workflowNameEditorRef} className="grid shrink-0">
<span
aria-hidden="true"
className="invisible col-start-1 row-start-1 whitespace-pre text-sm font-medium"
@ -214,7 +232,11 @@ export function Header() {
</span>
<input
value={workflowNameDraft}
onChange={(event) => setWorkflowNameDraft(event.target.value)}
onChange={(event) => {
lastCommittedWorkflowNameDraftRef.current = null;
setWorkflowNameDraft(event.target.value);
}}
onFocus={() => setIsEditingWorkflowName(true)}
onBlur={commitWorkflowName}
onKeyDown={(event) => {
if (event.key === "Enter") {

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

@ -1,4 +1,4 @@
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { NodeType } from "@/types";
import { useWorkflowStore } from "@/store/workflowStore";
@ -100,4 +100,75 @@ describe("FloatingNodeHeader", () => {
expect(screen.queryByTitle("Run this node")).not.toBeInTheDocument();
});
it("starts title editing on double click, not single click", () => {
render(
<FloatingNodeHeader
id="image-1"
type={"imageInput" as NodeType}
selected
title="Image Input"
/>
);
fireEvent.click(screen.getByText("Image Input"));
expect(screen.queryByDisplayValue("")).not.toBeInTheDocument();
fireEvent.doubleClick(screen.getByText("Image Input"));
expect(screen.getByPlaceholderText("Custom title...")).toBeInTheDocument();
});
it("commits title edits when clicking outside", () => {
const onCustomTitleChange = vi.fn();
render(
<>
<FloatingNodeHeader
id="image-1"
type={"imageInput" as NodeType}
selected
title="Image Input"
customTitle="Old Title"
onCustomTitleChange={onCustomTitleChange}
/>
<button type="button">Outside</button>
</>
);
fireEvent.doubleClick(screen.getByText("Old Title"));
fireEvent.change(screen.getByPlaceholderText("Custom title..."), { target: { value: "New Title" } });
fireEvent.pointerDown(screen.getByRole("button", { name: "Outside" }));
expect(onCustomTitleChange).toHaveBeenCalledWith("image-1", "New Title");
});
it("commits title edits when a selected node becomes unselected", () => {
const onCustomTitleChange = vi.fn();
const { rerender } = render(
<FloatingNodeHeader
id="image-1"
type={"imageInput" as NodeType}
selected
title="Image Input"
customTitle="Old Title"
onCustomTitleChange={onCustomTitleChange}
/>
);
fireEvent.doubleClick(screen.getByText("Old Title"));
fireEvent.change(screen.getByPlaceholderText("Custom title..."), { target: { value: "Selected Lost" } });
rerender(
<FloatingNodeHeader
id="image-1"
type={"imageInput" as NodeType}
selected={false}
title="Image Input"
customTitle="Old Title"
onCustomTitleChange={onCustomTitleChange}
/>
);
expect(onCustomTitleChange).toHaveBeenCalledWith("image-1", "Selected Lost");
});
});

2
src/components/__tests__/GenerateImageNode.test.tsx

@ -13,6 +13,7 @@ vi.mock("@/utils/deduplicatedFetch", () => ({
// Mock the workflow store
const mockUpdateNodeData = vi.fn();
const mockUpdateMediaNodeData = vi.fn();
const mockRegenerateNode = vi.fn();
const mockAddNode = vi.fn();
const mockOnConnect = vi.fn();
@ -144,6 +145,7 @@ describe("GenerateImageNode", () => {
mockUseWorkflowStore.mockImplementation((selector) => {
const state = {
updateNodeData: mockUpdateNodeData,
updateMediaNodeData: mockUpdateMediaNodeData,
regenerateNode: mockRegenerateNode,
addNode: mockAddNode,
onConnect: mockOnConnect,

18
src/components/__tests__/Header.test.tsx

@ -243,7 +243,7 @@ describe("Header", () => {
it("should update project name on blur", () => {
render(<Header />);
const nameInput = screen.getByRole("textbox", { name: "Workflow name" });
const nameInput = screen.getByRole("textbox");
fireEvent.change(nameInput, { target: { value: "Renamed Project" } });
fireEvent.blur(nameInput);
@ -251,6 +251,22 @@ describe("Header", () => {
expect(mockSetWorkflowName).toHaveBeenCalledWith("Renamed Project");
});
it("should update project name when clicking outside the name editor", () => {
render(
<>
<Header />
<button type="button">Outside</button>
</>
);
const nameInput = screen.getByRole("textbox");
fireEvent.focus(nameInput);
fireEvent.change(nameInput, { target: { value: "Outside Commit" } });
fireEvent.pointerDown(screen.getByRole("button", { name: "Outside" }));
expect(mockSetWorkflowName).toHaveBeenCalledWith("Outside Commit");
});
it("should render CostIndicator when project is configured", () => {
render(<Header />);
expect(screen.getByTestId("cost-indicator")).toBeInTheDocument();

34
src/components/nodes/FloatingNodeHeader.tsx

@ -6,6 +6,7 @@ import { NodeType, ProviderType } from "@/types";
import { useWorkflowStore } from "@/store/workflowStore";
import { DEFAULT_NODE_DIMENSIONS, defaultNodeDimensions } from "@/store/utils/nodeDefaults";
import { ProviderBadge } from "./ProviderBadge";
import { useCommitOnOutsidePointerDown } from "@/hooks/useCommitOnOutsidePointerDown";
export interface NodeHeaderMediaMeta {
kind: "image" | "video" | "audio" | "model";
@ -96,6 +97,9 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({
const [editTitleValue, setEditTitleValue] = useState(customTitle || "");
const titleInputRef = useRef<HTMLInputElement>(null);
const titleEditorRef = useRef<HTMLDivElement>(null);
const wasSelectedRef = useRef(selected);
const lastSubmittedTitleRef = useRef<string | null>(null);
// Sync state with props
useEffect(() => {
@ -115,6 +119,12 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({
// Title handlers
const handleTitleSubmit = useCallback(() => {
const trimmed = editTitleValue.trim();
if (lastSubmittedTitleRef.current === trimmed) {
setIsEditingTitle(false);
return;
}
lastSubmittedTitleRef.current = trimmed;
if (trimmed !== (customTitle || "")) {
onCustomTitleChange?.(id, trimmed);
}
@ -133,6 +143,19 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({
[handleTitleSubmit, customTitle]
);
useCommitOnOutsidePointerDown({
active: isEditingTitle,
rootRef: titleEditorRef,
onCommit: handleTitleSubmit,
});
useEffect(() => {
if (wasSelectedRef.current && !selected && isEditingTitle) {
handleTitleSubmit();
}
wasSelectedRef.current = selected;
}, [handleTitleSubmit, isEditingTitle, selected]);
// Determine if controls should be visible
const showControls = isHovered || selected;
const displayTitle = customTitle || mediaMeta?.name || title;
@ -300,7 +323,7 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({
onPointerDown={handleHeaderPointerDown}
>
{/* Title Section */}
<div className="flex min-w-0 max-w-full items-center gap-1.5 pl-1.5 pr-2">
<div ref={titleEditorRef} className="flex min-w-0 max-w-full items-center gap-1.5 pl-1.5 pr-2">
{mediaMeta ? (
<HeaderMediaIcon kind={mediaMeta.kind} />
) : (
@ -311,7 +334,10 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({
ref={titleInputRef}
type="text"
value={editTitleValue}
onChange={(e) => setEditTitleValue(e.target.value)}
onChange={(e) => {
lastSubmittedTitleRef.current = null;
setEditTitleValue(e.target.value);
}}
onBlur={handleTitleSubmit}
onKeyDown={handleTitleKeyDown}
placeholder="Custom title..."
@ -320,8 +346,8 @@ export const FloatingNodeHeader = memo(function FloatingNodeHeader({
) : (
<span
className={displayTitleClassName}
onClick={() => setIsEditingTitle(true)}
title="Click to edit title"
onDoubleClick={() => setIsEditingTitle(true)}
title="Double click to edit title"
>
{displayTitle}
</span>

14
src/components/nodes/GenerateImageNode.tsx

@ -597,19 +597,7 @@ export function GenerateImageNodeView({ id, data, selected }: GenerateImageNodeV
if (image.naturalWidth > 0 && image.naturalHeight > 0) {
const dimensions = { width: image.naturalWidth, height: image.naturalHeight };
setCropImageDimensions(dimensions);
const currentDimensions = asImageDimensions(nodeData.dimensions);
const loadedOriginalImage = renderedImageSrc === nodeData.outputImage || displayImage === nodeData.outputImage;
const shouldPersistLoadedDimensions =
loadedOriginalImage ||
!currentDimensions ||
!nodeData.outputImage ||
nodeData.outputImage === nodeData.previewImg;
if (
shouldPersistLoadedDimensions &&
(currentDimensions?.width !== dimensions.width || currentDimensions?.height !== dimensions.height)
) {
updateMediaNodeData(id, { dimensions }, dimensions);
}
updateMediaNodeData(id, { dimensions }, dimensions);
}
}}
onError={() => {

28
src/hooks/useCommitOnOutsidePointerDown.ts

@ -0,0 +1,28 @@
import { useEffect, type RefObject } from "react";
interface UseCommitOnOutsidePointerDownOptions {
active: boolean;
rootRef: RefObject<HTMLElement | null>;
onCommit: () => void;
}
export function useCommitOnOutsidePointerDown({
active,
rootRef,
onCommit,
}: UseCommitOnOutsidePointerDownOptions): void {
useEffect(() => {
if (!active) return;
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (target instanceof Node && rootRef.current?.contains(target)) return;
onCommit();
};
document.addEventListener("pointerdown", handlePointerDown, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
};
}, [active, onCommit, rootRef]);
}

17
src/store/workflowStore.ts

@ -1195,16 +1195,29 @@ function applyWorkflowNodeStyleSize(
size: { width: number; height: number } | null
): WorkflowNode {
if (!size) return node;
const {
width: _width,
height: _height,
measured: _measured,
...nodeWithoutReactFlowSize
} = node as WorkflowNode & {
width?: number;
height?: number;
measured?: { width?: number; height?: number };
};
const styleWidth = typeof node.style?.width === "number" ? node.style.width : undefined;
const styleHeight = typeof node.style?.height === "number" ? node.style.height : undefined;
if (
numbersMatch(styleWidth, size.width) &&
numbersMatch(styleHeight, size.height)
numbersMatch(styleHeight, size.height) &&
_width === undefined &&
_height === undefined &&
_measured === undefined
) {
return node;
}
return {
...node,
...nodeWithoutReactFlowSize,
style: {
...node.style,
width: size.width,

Loading…
Cancel
Save