From 83fab5f136134f2a3dedf48c60f23b6a06dbba46 Mon Sep 17 00:00:00 2001 From: ddCat <2695092045@qq.com> Date: Fri, 31 Jul 2026 17:17:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(canvas):=20=E8=8A=82=E7=82=B9=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=20-=20=E6=96=B0=E5=A2=9E=E5=A4=96=E7=BD=AE=E6=A0=87?= =?UTF-8?q?=E9=A2=98=E4=B8=8E=E8=B7=9F=E9=9A=8F=E9=94=9A=E7=82=B9=E5=BF=AB?= =?UTF-8?q?=E9=80=9F=E8=BF=9E=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/canvas/canvas-connections.tsx | 23 ++- .../canvas/canvas-leafer-graphics-layer.tsx | 4 +- .../canvas/canvas-node-hover-toolbar.tsx | 3 +- web/src/components/canvas/canvas-node.tsx | 160 ++++++++++++++++-- .../canvas/canvas-workspace-overlays.tsx | 1 + web/src/lib/canvas/canvas-project-domain.ts | 8 +- .../canvas/canvas-project-world-layers.tsx | 3 +- web/src/pages/canvas/project.tsx | 2 + .../use-canvas-connection-controller.ts | 66 ++++++-- web/src/types/canvas.ts | 3 + 10 files changed, 232 insertions(+), 41 deletions(-) diff --git a/web/src/components/canvas/canvas-connections.tsx b/web/src/components/canvas/canvas-connections.tsx index d49221340..09f200d3e 100644 --- a/web/src/components/canvas/canvas-connections.tsx +++ b/web/src/components/canvas/canvas-connections.tsx @@ -91,31 +91,31 @@ export const ConnectionPath = React.memo(function ConnectionPath({ export function canvasConnectionPath(connection: CanvasConnection, from: CanvasNodeData, to: CanvasNodeData, fromScrollTop = 0, toScrollTop = 0) { const startX = from.position.x + from.width; - const startY = connectionHandleY(from, connection.fromHandleId, fromScrollTop); + const startY = connectionHandleY(from, connection.fromHandleId, fromScrollTop, connection.fromAnchorRatio); const endX = to.position.x; - const endY = connectionHandleY(to, connection.toHandleId, toScrollTop); + const endY = connectionHandleY(to, connection.toHandleId, toScrollTop, connection.toAnchorRatio); const dx = Math.abs(endX - startX); const curvature = Math.max(dx * 0.5, 50); return { pathD: `M ${startX} ${startY} C ${startX + curvature} ${startY}, ${endX - curvature} ${endY}, ${endX} ${endY}`, startX, startY, endX, endY }; } -export function activeConnectionPath(node: CanvasNodeData | undefined, handle: ConnectionHandle, mouseWorld: Position, target?: CanvasNodeData, nodeScrollTop = 0) { +export function activeConnectionPath(node: CanvasNodeData | undefined, handle: ConnectionHandle, mouseWorld: Position, target?: CanvasNodeData, nodeScrollTop = 0, targetAnchorRatio?: number) { if (!node) return ""; const startX = handle.handleType === "source" ? node.position.x + node.width : mouseWorld.x; - const startY = handle.handleType === "source" ? connectionHandleY(node, handle.handleId, nodeScrollTop) : mouseWorld.y; + const startY = handle.handleType === "source" ? connectionHandleY(node, handle.handleId, nodeScrollTop, handle.anchorRatio) : mouseWorld.y; const endX = handle.handleType === "source" ? mouseWorld.x : node.position.x; - const endY = handle.handleType === "source" ? mouseWorld.y : connectionHandleY(node, handle.handleId, nodeScrollTop); + const endY = handle.handleType === "source" ? mouseWorld.y : connectionHandleY(node, handle.handleId, nodeScrollTop, handle.anchorRatio); const snappedStartX = handle.handleType === "target" && target ? target.position.x + target.width : startX; - const snappedStartY = handle.handleType === "target" && target ? target.position.y + target.height / 2 : startY; + const snappedStartY = handle.handleType === "target" && target ? connectionHandleY(target, undefined, 0, targetAnchorRatio) : startY; const snappedEndX = handle.handleType === "source" && target ? target.position.x : endX; - const snappedEndY = handle.handleType === "source" && target ? target.position.y + target.height / 2 : endY; + const snappedEndY = handle.handleType === "source" && target ? connectionHandleY(target, undefined, 0, targetAnchorRatio) : endY; const distance = Math.abs(snappedEndX - snappedStartX); return `M ${snappedStartX} ${snappedStartY} C ${snappedStartX + distance * 0.5} ${snappedStartY}, ${snappedEndX - distance * 0.5} ${snappedEndY}, ${snappedEndX} ${snappedEndY}`; } -export function connectionHandleY(node: CanvasNodeData, handleId?: string, scrollTop = 0) { +export function connectionHandleY(node: CanvasNodeData, handleId?: string, scrollTop = 0, anchorRatio?: number) { if (handleId === "storyboard:context") return node.position.y + node.height - (node.metadata?.storyboardComposerHeight || 104) / 2; - if (!handleId?.startsWith("row:")) return node.position.y + node.height / 2; + if (!handleId?.startsWith("row:")) return node.position.y + node.height * normalizedAnchorRatio(anchorRatio); const rowId = handleId.slice(4); const index = (node.metadata?.storyboard?.rows || []).findIndex((row) => row.id === rowId); if (index < 0) return node.position.y + node.height / 2; @@ -123,3 +123,8 @@ export function connectionHandleY(node: CanvasNodeData, handleId?: string, scrol const localY = Math.min(Math.max(index * STORYBOARD_ROW_HEIGHT + STORYBOARD_ROW_HEIGHT / 2 - scrollTop, 4), tableHeight - 4); return node.position.y + STORYBOARD_HEADER_HEIGHT + localY; } + +function normalizedAnchorRatio(value?: number) { + if (typeof value !== "number" || !Number.isFinite(value)) return 0.5; + return Math.min(0.94, Math.max(0.06, value)); +} diff --git a/web/src/components/canvas/canvas-leafer-graphics-layer.tsx b/web/src/components/canvas/canvas-leafer-graphics-layer.tsx index 424475dd7..f680b6b2e 100644 --- a/web/src/components/canvas/canvas-leafer-graphics-layer.tsx +++ b/web/src/components/canvas/canvas-leafer-graphics-layer.tsx @@ -20,6 +20,7 @@ type CanvasLeaferGraphicsLayerProps = { connectingParams: ConnectionHandle | null; mouseWorld: Position; connectionTargetNodeId: string | null; + connectionTargetAnchorRatio?: number; nodeById: Map; selectionBox: SelectionBox | null; selectedNodeBounds: NodeBounds; @@ -124,7 +125,7 @@ export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps) const overlay = overlayRef.current; if (!overlay) return; syncOverlayContent(overlay, props, viewportRef.current.k); - }, [props.connectingParams, props.connectionTargetNodeId, props.mouseWorld, props.nodeById, props.scriptScrollTopById, props.selectedNodeBounds, props.selectionBox, props.theme]); + }, [props.connectingParams, props.connectionTargetAnchorRatio, props.connectionTargetNodeId, props.mouseWorld, props.nodeById, props.scriptScrollTopById, props.selectedNodeBounds, props.selectionBox, props.theme]); useLayoutEffect(() => { const underlay = underlayRef.current; @@ -228,6 +229,7 @@ function syncOverlayContent(scene: OverlayScene, props: CanvasLeaferGraphicsLaye props.mouseWorld, props.connectionTargetNodeId ? props.nodeById.get(props.connectionTargetNodeId) : undefined, props.scriptScrollTopById[connecting.nodeId] || 0, + props.connectionTargetAnchorRatio, ), stroke: props.theme.accent.primary, strokeCap: "round", diff --git a/web/src/components/canvas/canvas-node-hover-toolbar.tsx b/web/src/components/canvas/canvas-node-hover-toolbar.tsx index f0aed4901..d8940fc18 100644 --- a/web/src/components/canvas/canvas-node-hover-toolbar.tsx +++ b/web/src/components/canvas/canvas-node-hover-toolbar.tsx @@ -162,7 +162,8 @@ export function CanvasNodeHoverToolbar({ const halfToolbar = toolbarWidth / 2; const canClamp = toolbarWidth > 0 && toolbarWidth <= containerRect.width - 20; const left = canClamp ? Math.min(Math.max(preferredLeft, halfToolbar + 10), containerRect.width - halfToolbar - 10) : preferredLeft; - const top = nodeRect.top - containerRect.top - 10; + // 外置节点标题占用节点上方一行,工具 Dock 再向上错开,避免遮挡名称编辑入口。 + const top = nodeRect.top - containerRect.top - 42; if (toolbarRef.current) { toolbarRef.current.style.left = `${left}px`; toolbarRef.current.style.top = `${top}px`; diff --git a/web/src/components/canvas/canvas-node.tsx b/web/src/components/canvas/canvas-node.tsx index 71166fcb1..d020f88e0 100644 --- a/web/src/components/canvas/canvas-node.tsx +++ b/web/src/components/canvas/canvas-node.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; -import { BookOpenCheck, ChevronRight, Clock3, FileText, Image as ImageIcon, LoaderCircle, Lock, Maximize2, Music2, Pencil, Play, RefreshCw, Replace, Square, Star, Video } from "lucide-react"; +import { BookOpenCheck, ChevronRight, Clapperboard, Clock3, FileText, Image as ImageIcon, LoaderCircle, Lock, Maximize2, Music2, Pencil, Play, Plus, RefreshCw, Replace, Settings2, Square, Star, Type, Video } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { CometCard } from "@/components/ui/aceternity/comet-card"; @@ -45,8 +45,9 @@ type CanvasNodeProps = { onMouseDown: (event: React.MouseEvent, nodeId: string) => void; onHoverStart: (nodeId: string) => void; onHoverEnd: (nodeId: string) => void; - onConnectStart: (event: React.PointerEvent, nodeId: string, handleType: "source" | "target", handleId?: string) => void; + onConnectStart: (event: React.PointerEvent, nodeId: string, handleType: "source" | "target", handleId?: string, anchorRatio?: number) => void; onResize: (nodeId: string, width: number, height: number, position?: Position) => void; + onTitleChange?: (nodeId: string, title: string) => void; onContentChange: (nodeId: string, content: string) => void; onToggleBatch?: (nodeId: string) => void; onSetBatchPrimary?: (node: CanvasNodeData) => void; @@ -93,7 +94,6 @@ export const CanvasNode = React.memo(function CanvasNode({ isRelated, isFocusRelated, isConnectionTarget, - isConnecting, showImageInfo, reduceMediaEffects = false, readOnly = false, @@ -112,6 +112,7 @@ export const CanvasNode = React.memo(function CanvasNode({ onHoverEnd, onConnectStart, onResize, + onTitleChange, onContentChange, onToggleBatch, onSetBatchPrimary, @@ -129,6 +130,8 @@ export const CanvasNode = React.memo(function CanvasNode({ const theme = canvasThemes[useThemeStore((state) => state.theme)]; const [hovered, setHovered] = useState(false); const [isEditingContent, setIsEditingContent] = useState(false); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [titleDraft, setTitleDraft] = useState(data.title); const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content); const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content); const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content); @@ -144,7 +147,7 @@ export const CanvasNode = React.memo(function CanvasNode({ const scriptMinHeight = data.type === CanvasNodeType.Script ? storyboardMinNodeHeight(data.metadata?.storyboardComposerHeight) : null; const cometDepth = hasMediaContent ? 6.8 : data.type === CanvasNodeType.Script ? 2.8 : 4.6; const cometTranslate = hasMediaContent ? 6 : data.type === CanvasNodeType.Script ? 2.5 : 4; - const cometDisabled = reduceMediaEffects || Boolean(dragOffset) || isEditingContent || isGeneratingNode || scale < 0.32 || batchClosing || batchOpening; + const cometDisabled = reduceMediaEffects || Boolean(dragOffset) || isEditingContent || isEditingTitle || isGeneratingNode || scale < 0.32 || batchClosing || batchOpening; const textareaRef = useRef(null); const resizeRef = useRef({ isResizing: false, @@ -175,6 +178,10 @@ export const CanvasNode = React.memo(function CanvasNode({ textarea?.setSelectionRange(textarea.value.length, textarea.value.length); }, [isEditingContent]); + useEffect(() => { + if (!isEditingTitle) setTitleDraft(data.title); + }, [data.title, isEditingTitle]); + useEffect(() => { if (!isEditingContent) return; @@ -263,6 +270,16 @@ export const CanvasNode = React.memo(function CanvasNode({ }; }, [handleResizeMove, handleResizeUp]); + const commitTitle = () => { + const next = titleDraft.trim(); + setIsEditingTitle(false); + if (!next) { + setTitleDraft(data.title); + return; + } + if (next !== data.title) onTitleChange?.(data.id, next); + }; + return (
onContextMenu(event, data.id)} > + setIsEditingTitle(true)} + onCommit={commitTitle} + onCancel={() => { setTitleDraft(data.title); setIsEditingTitle(false); }} + /> : null} - {!readOnly && data.type !== CanvasNodeType.Script ? onConnectStart(event, data.id, "target")} /> : null} - {!readOnly && data.type !== CanvasNodeType.Script ? onConnectStart(event, data.id, "source")} /> : null} + {!readOnly && data.type !== CanvasNodeType.Script ? onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null} + {!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config ? onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
); @@ -1089,20 +1119,120 @@ function ResizeHandle({ corner, onMouseDown }: { corner: ResizeCorner; onMouseDo return
onMouseDown(event, corner)} />; } -function ConnectionHandleDot({ side, scale, visible, theme, onPointerDown }: { side: "left" | "right"; scale: number; visible: boolean; theme: CanvasTheme; onPointerDown: (event: React.PointerEvent) => void }) { +function NodeExternalHeader({ node, scale, active, editable, editing, draft, theme, onDraftChange, onEdit, onCommit, onCancel }: { + node: CanvasNodeData; + scale: number; + active: boolean; + editable: boolean; + editing: boolean; + draft: string; + theme: CanvasTheme; + onDraftChange: (value: string) => void; + onEdit: () => void; + onCommit: () => void; + onCancel: () => void; +}) { const inverseScale = 1 / Math.max(scale, 0.05); + const Icon = nodeTypeIcon(node.type); return (
event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} > -
- -
+ + {editing ? ( + onDraftChange(event.target.value)} + onFocus={(event) => event.currentTarget.select()} + onBlur={onCommit} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") onCancel(); + }} + aria-label="节点名称" + /> + ) : editable ? ( + + ) : ( + {node.title} + )}
); } + +function nodeTypeIcon(type: CanvasNodeType) { + if (type === CanvasNodeType.Image) return ImageIcon; + if (type === CanvasNodeType.Video) return Video; + if (type === CanvasNodeType.Audio) return Music2; + if (type === CanvasNodeType.Drawing) return Pencil; + if (type === CanvasNodeType.Script) return Clapperboard; + if (type === CanvasNodeType.Config) return Settings2; + if (type === CanvasNodeType.Skill) return BookOpenCheck; + return Type; +} + +function ConnectionSideRail({ side, scale, visible, theme, onPointerDown }: { side: "left" | "right"; scale: number; visible: boolean; theme: CanvasTheme; onPointerDown: (event: React.PointerEvent, anchorRatio: number) => void }) { + const handleRef = useRef(null); + const anchorRatioRef = useRef(0.5); + const inverseScale = 1 / Math.max(scale, 0.05); + + const resetAnchor = useCallback(() => { + anchorRatioRef.current = 0.5; + if (handleRef.current) handleRef.current.style.top = "50%"; + }, []); + + useEffect(() => { + if (!visible) resetAnchor(); + }, [resetAnchor, visible]); + + const updateAnchor = (event: React.PointerEvent) => { + const railBounds = event.currentTarget.getBoundingClientRect(); + const nodeBounds = event.currentTarget.parentElement?.getBoundingClientRect() || railBounds; + const screenPadding = Math.min(railBounds.height * 0.35, 12); + const railRatio = Math.min(1 - screenPadding / Math.max(railBounds.height, 1), Math.max(screenPadding / Math.max(railBounds.height, 1), (event.clientY - railBounds.top) / Math.max(railBounds.height, 1))); + const anchorY = railBounds.top + railBounds.height * railRatio; + anchorRatioRef.current = Math.min(1, Math.max(0, (anchorY - nodeBounds.top) / Math.max(nodeBounds.height, 1))); + if (handleRef.current) handleRef.current.style.top = `${railRatio * 100}%`; + }; + + return ( + + ); +} diff --git a/web/src/components/canvas/canvas-workspace-overlays.tsx b/web/src/components/canvas/canvas-workspace-overlays.tsx index 46d8e671b..da322a7fc 100644 --- a/web/src/components/canvas/canvas-workspace-overlays.tsx +++ b/web/src/components/canvas/canvas-workspace-overlays.tsx @@ -12,6 +12,7 @@ import { CanvasNodeType, type CanvasNodeData, type ConnectionHandle, type Positi export type PendingConnectionCreate = { connection: ConnectionHandle; position: Position; + quick?: boolean; }; export function CanvasSelectionToolbar({ anchorRef, containerRef, count, children }: { anchorRef: RefObject; containerRef: RefObject; count: number; children: ReactNode }) { diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts index 8f5e9c82c..c6a2ed635 100644 --- a/web/src/lib/canvas/canvas-project-domain.ts +++ b/web/src/lib/canvas/canvas-project-domain.ts @@ -104,13 +104,17 @@ export function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial void; onNodeHoverStart: (nodeId: string) => void; onNodeHoverEnd: (nodeId: string) => void; - onConnectStart: (event: ReactPointerEvent, nodeId: string, handleType: "source" | "target", handleId?: string) => void; + onConnectStart: (event: ReactPointerEvent, nodeId: string, handleType: "source" | "target", handleId?: string, anchorRatio?: number) => void; onNodeResize: (nodeId: string, width: number, height: number, position?: Position) => void; onToggleFrame: (nodeId: string) => void; onNodeTitleChange: (nodeId: string, title: string) => void; @@ -139,6 +139,7 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) { onHoverEnd={props.onNodeHoverEnd} onConnectStart={props.onConnectStart} onResize={props.onNodeResize} + onTitleChange={props.onNodeTitleChange} onContentChange={props.onNodeContentChange} onToggleBatch={props.onToggleBatch} onSetBatchPrimary={props.onSetBatchPrimary} diff --git a/web/src/pages/canvas/project.tsx b/web/src/pages/canvas/project.tsx index 57d86f93a..278567c70 100644 --- a/web/src/pages/canvas/project.tsx +++ b/web/src/pages/canvas/project.tsx @@ -616,6 +616,7 @@ function InfiniteCanvasPage() { const { cancelPendingConnectionCreate, closeConnectionCreateMenu, + connectionTargetAnchorRatio, connectionTargetNodeId, connectingParams, createConnectedNode, @@ -1417,6 +1418,7 @@ function InfiniteCanvasPage() { connectingParams={connectingParams} mouseWorld={mouseWorld} connectionTargetNodeId={connectionTargetNodeId} + connectionTargetAnchorRatio={connectionTargetAnchorRatio} nodeById={nodeById} selectionBox={selectionBox} selectedNodeBounds={selectedNodeBounds} diff --git a/web/src/pages/canvas/use-canvas-connection-controller.ts b/web/src/pages/canvas/use-canvas-connection-controller.ts index 31a3bdb45..d7b57c80d 100644 --- a/web/src/pages/canvas/use-canvas-connection-controller.ts +++ b/web/src/pages/canvas/use-canvas-connection-controller.ts @@ -3,6 +3,7 @@ import { App } from "antd"; import { nanoid } from "nanoid"; import type { PendingConnectionCreate } from "@/components/canvas/canvas-workspace-overlays"; +import { getNodeSpec } from "@/constant/canvas"; import { attachNodeToStoryboardRow, createCanvasNode, getConnectionTargetAnchor, isHiddenBatchChild, normalizeConnection, storyboardHandleAtY, storyboardRowFromHandle } from "@/lib/canvas/canvas-project-domain"; import { createCanvasDrawingFromImage } from "@/lib/canvas/canvas-drawing-storage"; import { isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame"; @@ -27,6 +28,7 @@ type UseCanvasConnectionControllerOptions = { type ConnectionDropTarget = { nodeId: string | null; handleId?: string; + anchorRatio?: number; isNearNode: boolean; }; @@ -52,10 +54,12 @@ export function useCanvasConnectionController({ const { message } = App.useApp(); const [connectingParams, setConnectingParams] = useState(null); const [connectionTargetNodeId, setConnectionTargetNodeId] = useState(null); + const [connectionTargetAnchorRatio, setConnectionTargetAnchorRatio] = useState(); const [pendingConnectionCreate, setPendingConnectionCreate] = useState(null); const [mouseWorld, setMouseWorld] = useState({ x: 0, y: 0 }); const connectingParamsRef = useRef(connectingParams); const connectingPointerIdRef = useRef(null); + const connectingPointerStartRef = useRef(null); const pendingConnectionCreateRef = useRef(pendingConnectionCreate); useLayoutEffect(() => { @@ -68,7 +72,9 @@ export function useCanvasConnectionController({ setConnectingParams(next); if (!next) { connectingPointerIdRef.current = null; + connectingPointerStartRef.current = null; setConnectionTargetNodeId(null); + setConnectionTargetAnchorRatio(undefined); } }, []); @@ -82,7 +88,7 @@ export function useCanvasConnectionController({ setConnecting(null); }, [closeConnectionCreateMenu, setConnecting]); - const connectNodes = useCallback((current: ConnectionHandle, targetNodeId: string, targetHandleId?: string) => { + const connectNodes = useCallback((current: ConnectionHandle, targetNodeId: string, targetHandleId?: string, targetAnchorRatio?: number) => { if (current.nodeId === targetNodeId) return; const connection = normalizeConnection(current.nodeId, targetNodeId, nodesRef.current, current.handleType); if (!connection) { @@ -92,9 +98,13 @@ export function useCanvasConnectionController({ const { fromNodeId, toNodeId } = connection; const fromHandleId = fromNodeId === current.nodeId ? current.handleId : targetHandleId; const toHandleId = toNodeId === current.nodeId ? current.handleId : targetHandleId; - const exists = connectionsRef.current.some((item) => item.fromNodeId === fromNodeId && item.toNodeId === toNodeId && item.fromHandleId === fromHandleId && item.toHandleId === toHandleId); - if (!exists) { - setConnections((currentConnections) => [...currentConnections, { id: `conn-${Date.now()}`, fromNodeId, toNodeId, fromHandleId, toHandleId }]); + const fromAnchorRatio = fromNodeId === current.nodeId ? current.anchorRatio : targetAnchorRatio; + const toAnchorRatio = toNodeId === current.nodeId ? current.anchorRatio : targetAnchorRatio; + const exists = connectionsRef.current.find((item) => item.fromNodeId === fromNodeId && item.toNodeId === toNodeId && item.fromHandleId === fromHandleId && item.toHandleId === toHandleId); + if (exists) { + setConnections((currentConnections) => currentConnections.map((item) => item.id === exists.id ? { ...item, fromAnchorRatio, toAnchorRatio } : item)); + } else { + setConnections((currentConnections) => [...currentConnections, { id: `conn-${Date.now()}`, fromNodeId, toNodeId, fromHandleId, toHandleId, fromAnchorRatio, toAnchorRatio }]); setNodes((currentNodes) => attachNodeToStoryboardRow(currentNodes, { fromNodeId, toNodeId, fromHandleId, toHandleId })); } setContextMenu(null); @@ -110,7 +120,18 @@ export function useCanvasConnectionController({ : type === CanvasNodeType.Video && storyboardRow ? { prompt: videoPrompt, composerContent: videoPrompt, generationMode: "video" as const, videoEditOperation: "text_to_video" as const, workflowKind: "shot" as const, workflowTitle: `镜头 ${storyboardRow.shotNumber} 视频`, shotIndex: storyboardRow.shotNumber, seconds: String(storyboardRow.durationSeconds), status: NODE_STATUS_IDLE } : undefined; - const newNode = createCanvasNode(type, pending.position, metadata); + const sourceNodeForQuickCreate = pending.quick ? nodesRef.current.find((node) => node.id === pending.connection.nodeId) : undefined; + const spec = getNodeSpec(type); + const anchorY = sourceNodeForQuickCreate ? sourceNodeForQuickCreate.position.y + sourceNodeForQuickCreate.height * (pending.connection.anchorRatio ?? 0.5) : pending.position.y; + const position = sourceNodeForQuickCreate + ? { + x: pending.connection.handleType === "source" + ? sourceNodeForQuickCreate.position.x + sourceNodeForQuickCreate.width + 96 + spec.width / 2 + : sourceNodeForQuickCreate.position.x - 96 - spec.width / 2, + y: anchorY, + } + : pending.position; + const newNode = createCanvasNode(type, position, metadata); if (storyboardRow) newNode.title = `镜头 ${storyboardRow.shotNumber} · 视频`; const connection = normalizeConnection(pending.connection.nodeId, newNode.id, [...nodesRef.current, newNode], pending.connection.handleType); if (!connection) { @@ -148,7 +169,9 @@ export function useCanvasConnectionController({ } const fromHandleId = connection.fromNodeId === pending.connection.nodeId ? pending.connection.handleId : undefined; const toHandleId = connection.toNodeId === pending.connection.nodeId ? pending.connection.handleId : undefined; - const connected = { ...connection, fromHandleId, toHandleId }; + const fromAnchorRatio = connection.fromNodeId === pending.connection.nodeId ? pending.connection.anchorRatio : 0.5; + const toAnchorRatio = connection.toNodeId === pending.connection.nodeId ? pending.connection.anchorRatio : 0.5; + const connected = { ...connection, fromHandleId, toHandleId, fromAnchorRatio, toAnchorRatio }; setNodes((currentNodes) => attachNodeToStoryboardRow([...currentNodes, newNode], connected)); setConnections((currentConnections) => [...currentConnections, { id: nanoid(), ...connected }]); setSelectedNodeIds(new Set([newNode.id])); @@ -167,6 +190,7 @@ export function useCanvasConnectionController({ let isNearNode = false; let bestNodeId: string | null = null; let bestHandleId: string | undefined; + let bestAnchorRatio: number | undefined; let bestPriority = Number.POSITIVE_INFINITY; [...nodesRef.current] @@ -176,7 +200,8 @@ export function useCanvasConnectionController({ const scrollTop = scriptScrollTopById[node.id] || 0; const targetHandleId = node.type === CanvasNodeType.Script ? storyboardHandleAtY(node, world.y, scrollTop) : undefined; if (node.type === CanvasNodeType.Script && !targetHandleId) return; - const anchor = getConnectionTargetAnchor(node, current, targetHandleId, scrollTop); + const targetAnchorRatio = node.type === CanvasNodeType.Script ? undefined : Math.min(0.94, Math.max(0.06, (world.y - node.position.y) / Math.max(node.height, 1))); + const anchor = getConnectionTargetAnchor(node, current, targetHandleId, scrollTop, targetAnchorRatio); const dx = world.x - anchor.x; const dy = world.y - anchor.y; const hitsHandle = dx * dx + dy * dy <= handleRadius * handleRadius; @@ -189,10 +214,11 @@ export function useCanvasConnectionController({ if (priority < bestPriority) { bestNodeId = node.id; bestHandleId = targetHandleId; + bestAnchorRatio = targetAnchorRatio; bestPriority = priority; } }); - return { nodeId: bestNodeId, handleId: bestHandleId, isNearNode }; + return { nodeId: bestNodeId, handleId: bestHandleId, anchorRatio: bestAnchorRatio, isNearNode }; }, [nodesRef, screenToCanvas, scriptScrollTopById, viewportRef]); const finishConnection = useCallback((clientX: number, clientY: number) => { @@ -201,7 +227,7 @@ export function useCanvasConnectionController({ if (!currentConnection) return; const dropTarget = getConnectionDropTarget(clientX, clientY, currentConnection); if (dropTarget.nodeId) { - connectNodes(currentConnection, dropTarget.nodeId, dropTarget.handleId); + connectNodes(currentConnection, dropTarget.nodeId, dropTarget.handleId, dropTarget.anchorRatio); setConnecting(null); } else if (dropTarget.isNearNode) { setConnecting(null); @@ -214,13 +240,15 @@ export function useCanvasConnectionController({ } }, [connectNodes, getConnectionDropTarget, screenToCanvas, setConnecting]); - const handleConnectStart = useCallback((event: ReactPointerEvent, nodeId: string, handleType: "source" | "target", handleId?: string) => { + const handleConnectStart = useCallback((event: ReactPointerEvent, nodeId: string, handleType: "source" | "target", handleId?: string, anchorRatio?: number) => { event.preventDefault(); event.stopPropagation(); connectingPointerIdRef.current = event.pointerId; + connectingPointerStartRef.current = { x: event.clientX, y: event.clientY }; setMouseWorld(screenToCanvas(event.clientX, event.clientY)); - setConnecting({ nodeId, handleType, handleId }); + setConnecting({ nodeId, handleType, handleId, anchorRatio }); setConnectionTargetNodeId(null); + setConnectionTargetAnchorRatio(undefined); setSelectedConnectionId(null); }, [screenToCanvas, setConnecting, setSelectedConnectionId]); @@ -230,10 +258,23 @@ export function useCanvasConnectionController({ if (!current || connectingPointerIdRef.current !== event.pointerId || pendingConnectionCreateRef.current) return; const dropTarget = getConnectionDropTarget(event.clientX, event.clientY, current); setConnectionTargetNodeId(dropTarget.nodeId); + setConnectionTargetAnchorRatio(dropTarget.anchorRatio); setMouseWorld(screenToCanvas(event.clientX, event.clientY)); }; const handlePointerUp = (event: PointerEvent) => { - if (connectingPointerIdRef.current === event.pointerId) finishConnection(event.clientX, event.clientY); + if (connectingPointerIdRef.current !== event.pointerId) return; + const start = connectingPointerStartRef.current; + if (start && Math.hypot(event.clientX - start.x, event.clientY - start.y) <= 5) { + const current = connectingParamsRef.current; + if (current) { + const pending = { connection: current, position: screenToCanvas(event.clientX, event.clientY), quick: true }; + pendingConnectionCreateRef.current = pending; + setPendingConnectionCreate(pending); + setConnecting(null); + return; + } + } + finishConnection(event.clientX, event.clientY); }; const handlePointerCancel = (event: PointerEvent) => { if (connectingPointerIdRef.current === event.pointerId) setConnecting(null); @@ -257,6 +298,7 @@ export function useCanvasConnectionController({ cancelPendingConnectionCreate, closeConnectionCreateMenu, connectionTargetNodeId, + connectionTargetAnchorRatio, connectingParams, createConnectedNode, handleConnectStart, diff --git a/web/src/types/canvas.ts b/web/src/types/canvas.ts index 438c60b20..a425af359 100644 --- a/web/src/types/canvas.ts +++ b/web/src/types/canvas.ts @@ -276,6 +276,8 @@ export type CanvasConnection = { toNodeId: string; fromHandleId?: string; toHandleId?: string; + fromAnchorRatio?: number; + toAnchorRatio?: number; }; export type CanvasDisplayConnection = { @@ -331,6 +333,7 @@ export type ConnectionHandle = { nodeId: string; handleType: "source" | "target"; handleId?: string; + anchorRatio?: number; }; export type SelectionBox = {