Files
ttstd-infinite-canvas/web/src/components/canvas/canvas-connections.tsx

189 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState } from "react";
import type { MouseEvent as ReactMouseEvent } from "react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/lib/canvas/canvas-storyboard-layout";
import { batchReferenceHandleY } from "@/lib/canvas/canvas-batch-table";
import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position } from "@/types/canvas";
export const ConnectionPath = React.memo(function ConnectionPath({
connection,
from,
to,
fromScrollTop = 0,
toScrollTop = 0,
active,
visualMode = "full",
hideVisual = false,
onSelect,
onContextMenu,
}: {
connection: CanvasConnection;
from: CanvasNodeData;
to: CanvasNodeData;
fromScrollTop?: number;
toScrollTop?: number;
active: boolean;
visualMode?: "full" | "hover-only";
hideVisual?: boolean;
onSelect: () => void;
onContextMenu?: (event: ReactMouseEvent<SVGPathElement>) => void;
}) {
const theme = canvasThemes[useActiveTheme()];
const [hovered, setHovered] = useState(false);
const { pathD, startX, startY, endX, endY } = canvasConnectionPath(connection, from, to, fromScrollTop, toScrollTop);
const emphasized = active || hovered;
const showVisual = !hideVisual && (visualMode === "full" || hovered);
const showEmphasis = !hideVisual && emphasized;
const gradientId = `canvas-flow-${connection.id.replace(/[^a-zA-Z0-9_-]/g, "")}`;
return (
<g>
{showEmphasis ? <defs>
<linearGradient id={gradientId} gradientUnits="userSpaceOnUse" x1={startX} y1={startY} x2={endX} y2={endY}>
<stop offset="0%" stopColor={theme.node.muted} stopOpacity={0.18} />
<stop offset="48%" stopColor={theme.accent.primary} stopOpacity={0.58} />
<stop offset="100%" stopColor={theme.accent.primary} stopOpacity={0.34} />
</linearGradient>
{/* 流光头部的软化渐变:两端透明、中间亮,避免短划线看起来是硬色块 */}
<linearGradient id={`${gradientId}-comet`} x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={theme.accent.primary} stopOpacity={0} />
<stop offset="45%" stopColor={theme.accent.primary} stopOpacity={0.95} />
<stop offset="100%" stopColor={theme.accent.primary} stopOpacity={0} />
</linearGradient>
</defs> : null}
{/* 光晕:只在强调态渲染。blur 是 filter,成本随线条数量线性上升,
常态几十条线全开会明显掉帧,所以刻意只给悬停/选中的那一条。
垫在底衬描边之下,不改动常态可读性那几层。 */}
{showEmphasis ? <path
d={pathD}
stroke={theme.accent.primary}
strokeWidth="8"
vectorEffect="non-scaling-stroke"
strokeOpacity={0.18}
fill="none"
strokeLinecap="round"
style={{ pointerEvents: "none", filter: "blur(3px)" }}
/> : null}
<path
data-connection-id={connection.id}
d={pathD}
stroke="transparent"
strokeWidth="16"
vectorEffect="non-scaling-stroke"
fill="none"
style={{ cursor: "pointer", pointerEvents: "stroke" }}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onClick={(event) => {
event.stopPropagation();
onSelect();
}}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
onContextMenu?.(event);
}}
/>
{showVisual ? <path
d={pathD}
stroke={theme.node.muted}
strokeWidth={emphasized ? 5 : 3.5}
vectorEffect="non-scaling-stroke"
strokeOpacity={emphasized ? 0.18 : 0.16}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
style={{ pointerEvents: "none" }}
/> : null}
{showVisual ? <path
d={pathD}
stroke={emphasized ? theme.accent.primary : theme.node.muted}
strokeWidth={emphasized ? 2.8 : 2}
vectorEffect="non-scaling-stroke"
strokeOpacity={emphasized ? 0.95 : 0.8}
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
style={{ pointerEvents: "none" }}
/> : null}
{showVisual ? <>
<circle cx={startX} cy={startY} r={emphasized ? 3.5 : 2.5} fill={emphasized ? theme.accent.primary : theme.node.muted} fillOpacity={emphasized ? 0.9 : 0.72} vectorEffect="non-scaling-stroke" style={{ pointerEvents: "none" }} />
<circle cx={endX} cy={endY} r={emphasized ? 3.5 : 2.5} fill={emphasized ? theme.accent.primary : theme.node.muted} fillOpacity={emphasized ? 0.9 : 0.72} vectorEffect="non-scaling-stroke" style={{ pointerEvents: "none" }} />
</> : null}
{showVisual && emphasized ? <path
className="canvas-connection-flow"
d={pathD}
stroke={`url(#${gradientId})`}
strokeWidth="2.2"
vectorEffect="non-scaling-stroke"
strokeOpacity="0.84"
strokeDasharray="18 26"
fill="none"
strokeLinecap="round"
strokeLinejoin="round"
style={{ pointerEvents: "none" }}
/> : null}
{/* 流光:一小段高亮沿路径跑。周期与虚线流动刻意不同(2.1s vs 1.25s),
两者错拍才像有光在走;同频会锁成一条整体平移的虚线。 */}
{showEmphasis ? <path
className="canvas-connection-comet"
d={pathD}
stroke={`url(#${gradientId}-comet)`}
strokeWidth="2.6"
vectorEffect="non-scaling-stroke"
strokeDasharray="16 118"
fill="none"
strokeLinecap="round"
style={{ pointerEvents: "none" }}
/> : null}
</g>
);
}, (previous, next) => previous.connection === next.connection && previous.from === next.from && previous.to === next.to && previous.active === next.active && previous.visualMode === next.visualMode && previous.hideVisual === next.hideVisual && 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);
const endX = to.position.x;
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) {
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 endX = handle.handleType === "source" ? mouseWorld.x : node.position.x;
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) : startY;
const snappedEndX = handle.handleType === "source" && target ? target.position.x : endX;
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}`;
}
/**
* 连线在节点上的接入 Y。
*
* 单端口的一侧(左右各一个出入口,除分镜脚本外都是)**永远取边的正中**:
* 之前按鼠标落点比例取 Y,多条线接同一个端口就会沿着边散开,视觉上像节点有很多端口。
* 真正的多端口只存在于分镜脚本的 `row:` 句柄;普通节点始终连接到边缘
* 垂直中心,避免同一个节点因鼠标落点产生漂移的“伪端口”。
*/
export function connectionHandleY(node: CanvasNodeData, handleId?: string, scrollTop = 0) {
const batchY = batchReferenceHandleY(node, handleId);
if (batchY !== undefined) return batchY;
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;
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;
const tableHeight = storyboardTableHeight(node.height, node.metadata?.storyboardComposerHeight);
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;
}