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

131 lines
6.9 KiB
TypeScript

import React, { useState } from "react";
import type { MouseEvent as ReactMouseEvent } from "react";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { STORYBOARD_HEADER_HEIGHT, STORYBOARD_ROW_HEIGHT, storyboardTableHeight } from "@/components/canvas/canvas-script-node";
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",
onSelect,
onContextMenu,
}: {
connection: CanvasConnection;
from: CanvasNodeData;
to: CanvasNodeData;
fromScrollTop?: number;
toScrollTop?: number;
active: boolean;
visualMode?: "full" | "hover-only";
onSelect: () => void;
onContextMenu?: (event: ReactMouseEvent<SVGPathElement>) => void;
}) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const [hovered, setHovered] = useState(false);
const { pathD, startX, startY, endX, endY } = canvasConnectionPath(connection, from, to, fromScrollTop, toScrollTop);
const emphasized = active || hovered;
const showVisual = visualMode === "full" || hovered;
const gradientId = `canvas-flow-${connection.id.replace(/[^a-zA-Z0-9_-]/g, "")}`;
return (
<g>
{emphasized ? <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>
</defs> : 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={emphasized ? theme.accent.primary : theme.node.muted}
strokeWidth={emphasized ? 1.6 : 1}
vectorEffect="non-scaling-stroke"
strokeOpacity={emphasized ? 0.52 : 0.24}
fill="none"
strokeLinecap="round"
style={{ pointerEvents: "none" }}
/> : null}
{showVisual && emphasized ? <path
className="canvas-connection-flow"
d={pathD}
stroke={`url(#${gradientId})`}
strokeWidth="1.8"
vectorEffect="non-scaling-stroke"
strokeOpacity="1"
strokeDasharray="18 26"
fill="none"
strokeLinecap="round"
style={{ filter: `drop-shadow(0 0 3px ${theme.accent.primary}35)`, 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.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 endX = to.position.x;
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, 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, 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, handle.anchorRatio);
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 snappedEndX = handle.handleType === "source" && target ? target.position.x : endX;
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, 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 * 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;
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;
}
function normalizedAnchorRatio(value?: number) {
if (typeof value !== "number" || !Number.isFinite(value)) return 0.5;
return Math.min(0.94, Math.max(0.06, value));
}