diff --git a/web/src/components/canvas/canvas-connections.tsx b/web/src/components/canvas/canvas-connections.tsx index f674c25dc..b1c61c805 100644 --- a/web/src/components/canvas/canvas-connections.tsx +++ b/web/src/components/canvas/canvas-connections.tsx @@ -42,7 +42,26 @@ export const ConnectionPath = React.memo(function ConnectionPath({ + {/* 流光头部的软化渐变:两端透明、中间亮,避免短划线看起来是硬色块 */} + + + + + : null} + {/* 光晕:只在强调态渲染。blur 是 filter,成本随线条数量线性上升, + 常态几十条线全开会明显掉帧,所以刻意只给悬停/选中的那一条。 + 垫在底衬描边之下,不改动常态可读性那几层。 */} + {emphasized ? : null} : null} + {/* 流光:一小段高亮沿路径跑。周期与虚线流动刻意不同(2.1s vs 1.25s), + 两者错拍才像有光在走;同频会锁成一条整体平移的虚线。 */} + {emphasized ? : null} ); }, (previous, next) => previous.connection === next.connection && previous.from === next.from && previous.to === next.to && previous.active === next.active && previous.visualMode === next.visualMode && previous.fromScrollTop === next.fromScrollTop && previous.toScrollTop === next.toScrollTop); 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, connection.fromAnchorRatio); + const startY = connectionHandleY(from, connection.fromHandleId, fromScrollTop); const endX = to.position.x; - const endY = connectionHandleY(to, connection.toHandleId, toScrollTop, connection.toAnchorRatio); + const endY = connectionHandleY(to, connection.toHandleId, toScrollTop); 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, targetAnchorRatio?: number) { +export function activeConnectionPath(node: CanvasNodeData | undefined, handle: ConnectionHandle, mouseWorld: Position, target?: CanvasNodeData, nodeScrollTop = 0) { 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, handle.anchorRatio) : mouseWorld.y; + const startY = handle.handleType === "source" ? connectionHandleY(node, handle.handleId, nodeScrollTop) : mouseWorld.y; const endX = handle.handleType === "source" ? mouseWorld.x : node.position.x; - const endY = handle.handleType === "source" ? mouseWorld.y : connectionHandleY(node, handle.handleId, nodeScrollTop, handle.anchorRatio); + const endY = handle.handleType === "source" ? mouseWorld.y : connectionHandleY(node, handle.handleId, nodeScrollTop); const snappedStartX = handle.handleType === "target" && target ? target.position.x + target.width : startX; - const snappedStartY = handle.handleType === "target" && target ? connectionHandleY(target, undefined, 0, targetAnchorRatio) : startY; + const snappedStartY = handle.handleType === "target" && target ? connectionHandleY(target) : startY; const snappedEndX = handle.handleType === "source" && target ? target.position.x : endX; - const snappedEndY = handle.handleType === "source" && target ? connectionHandleY(target, undefined, 0, targetAnchorRatio) : endY; + const snappedEndY = handle.handleType === "source" && target ? connectionHandleY(target) : 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, anchorRatio?: number) { +/** + * 连线在节点上的接入 Y。 + * + * 单端口的一侧(左右各一个出入口,除分镜脚本外都是)**永远取边的正中**: + * 之前按鼠标落点比例取 Y,多条线接同一个端口就会沿着边散开,视觉上像节点有很多端口。 + * 真正的多端口只存在于分镜脚本的 `row:` 句柄,走下面的分支——所以这里不再接受 + * anchorRatio 参数,避免「传了却被忽略」。 + */ +export function connectionHandleY(node: CanvasNodeData, handleId?: string, scrollTop = 0) { 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 * normalizedAnchorRatio(anchorRatio); + if (!handleId?.startsWith("row:")) return node.position.y + node.height / 2; 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; @@ -140,8 +180,3 @@ 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 d68940c78..47cea0404 100644 --- a/web/src/components/canvas/canvas-leafer-graphics-layer.tsx +++ b/web/src/components/canvas/canvas-leafer-graphics-layer.tsx @@ -234,7 +234,6 @@ 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", @@ -253,7 +252,7 @@ function syncOverlayContent(scene: OverlayScene, props: CanvasLeaferGraphicsLaye if (!source) return; const handle: ConnectionHandle = { nodeId: source.id, handleType: "source" }; scene.batchDrafts.add(new Path({ - path: activeConnectionPath(source, handle, batch.mouseWorld, target, props.scriptScrollTopById[source.id] || 0, batch.targetAnchorRatio), + path: activeConnectionPath(source, handle, batch.mouseWorld, target, props.scriptScrollTopById[source.id] || 0), stroke, strokeWidth: 1.4, strokeScaleFixed: true, diff --git a/web/src/lib/canvas/canvas-project-domain.ts b/web/src/lib/canvas/canvas-project-domain.ts index bded5cbf2..f6ad13008 100644 --- a/web/src/lib/canvas/canvas-project-domain.ts +++ b/web/src/lib/canvas/canvas-project-domain.ts @@ -165,17 +165,17 @@ function resetGenerationParamsOnModelSwitch(node: CanvasNodeData, patch: Partial return { ...reset, ...patch }; } -export function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle, handleId?: string, scrollTop = 0, anchorRatio?: number) { +/** + * 连线落到目标节点上的吸附点。单端口一侧取边的正中——与 connectionHandleY 保持同一个 + * 口径,否则吸附点和实际画出来的线会对不上(这两处是同一规则的两份实现,改一处必错)。 + */ +export function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle, handleId?: string, scrollTop = 0) { return { x: current.handleType === "source" ? node.position.x : node.position.x + node.width, - y: storyboardHandleY(node, handleId, scrollTop) ?? node.position.y + node.height * normalizeAnchorRatio(anchorRatio), + y: storyboardHandleY(node, handleId, scrollTop) ?? node.position.y + node.height / 2, }; } -function normalizeAnchorRatio(value?: number) { - return typeof value === "number" && Number.isFinite(value) ? clamp(value, 0.06, 0.94) : 0.5; -} - export function storyboardHandleAtY(node: CanvasNodeData, worldY: number, scrollTop = 0) { const rows = node.metadata?.storyboard?.rows || []; const localY = worldY - node.position.y - STORYBOARD_HEADER_HEIGHT; diff --git a/web/src/pages/canvas/use-canvas-connection-controller.ts b/web/src/pages/canvas/use-canvas-connection-controller.ts index 95ea10076..65406bc2e 100644 --- a/web/src/pages/canvas/use-canvas-connection-controller.ts +++ b/web/src/pages/canvas/use-canvas-connection-controller.ts @@ -337,7 +337,7 @@ export function useCanvasConnectionController({ const targetHandleId = node.type === CanvasNodeType.Script ? storyboardHandleAtY(node, world.y, scrollTop) : undefined; if (node.type === CanvasNodeType.Script && !targetHandleId) return; 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 anchor = getConnectionTargetAnchor(node, current, targetHandleId, scrollTop); const dx = world.x - anchor.x; const dy = world.y - anchor.y; const hitsHandle = dx * dx + dy * dy <= handleRadius * handleRadius; @@ -383,7 +383,7 @@ export function useCanvasConnectionController({ const targetHandleId = node.type === CanvasNodeType.Script ? storyboardHandleAtY(node, world.y, scrollTop) : undefined; if (node.type === CanvasNodeType.Script && !targetHandleId) return; 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 anchor = getConnectionTargetAnchor(node, current, targetHandleId, scrollTop); const dx = world.x - anchor.x; const dy = world.y - anchor.y; const hitsHandle = dx * dx + dy * dy <= handleRadius * handleRadius; diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index c9c44a3ff..c82596fa9 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -271,6 +271,7 @@ /* infinite 装饰动画在 reduced-motion / no-motion 下降级(对应 #99 三级降级)*/ @media (prefers-reduced-motion: reduce) { .canvas-connection-flow, + .canvas-connection-comet, .canvas-connection-draft, .canvas-task-progress-shimmer, .ai-title-aurora, @@ -282,6 +283,7 @@ } } .no-motion .canvas-connection-flow, +.no-motion .canvas-connection-comet, .no-motion .canvas-connection-draft, .no-motion .canvas-task-progress-shimmer, .no-motion .ai-title-aurora, @@ -7161,7 +7163,7 @@ will-change: transform; } - [data-canvas-viewport-interacting="true"] :where(.canvas-connection-flow, .canvas-connection-draft) { + [data-canvas-viewport-interacting="true"] :where(.canvas-connection-flow, .canvas-connection-comet, .canvas-connection-draft) { animation: none !important; } @@ -7185,6 +7187,12 @@ animation: canvas-connection-flow 1.25s var(--motion-ease-linear) infinite; } + /* 流光:一小段高亮沿连线跑。周期与上面的虚线流动刻意错开,两者同频会锁成 + 一条整体平移的虚线、看不出「有光在走」。只在悬停/选中态渲染(见 canvas-connections.tsx)。 */ + .canvas-connection-comet { + animation: canvas-connection-comet 2.1s var(--motion-ease-linear) infinite; + } + .canvas-connection-draft { animation: canvas-connection-flow 0.9s var(--motion-ease-linear) infinite; } @@ -7207,6 +7215,13 @@ } } + /* 位移量必须等于 dasharray 之和(16 + 118),否则循环处会跳一下 */ + @keyframes canvas-connection-comet { + to { + stroke-dashoffset: -134; + } + } + .canvas-node-info-modal .ant-modal-header { padding-right: 0;