fix(canvas): 画布连线 - 端点收敛到端口中点并新增流动光影
两件事,都在连线层。 一、多条线接同一个端口时不再沿着边散开。 原因是 use-canvas-connection-controller 把**鼠标松手的 Y 位置**记成了 anchorRatio, 于是每条线各自钉在落点上。判据还是反的:真正多端口的分镜脚本传 undefined, 而左右各只有一个出入口的普通节点却按落点比例取 Y,看起来像节点有很多端口。 改成单端口一侧永远取边的正中。这条规则有**两份实现**(canvas-connections 的 connectionHandleY 与 canvas-project-domain 的 getConnectionTargetAnchor),只改一处 会让实际画出来的线和吸附点对不上,所以两处一起改。同时把 anchorRatio 参数从这两个 几何函数上摘掉,让「不按落点」写在签名里而不是悄悄丢掉一个入参 —— 摘掉之后编译器 点出了 4 处仍在传旧参数的调用,正是这么做的收益。分镜脚本的 row: 句柄不受影响。 二、连线新增光晕与流光两层。 光晕(blur 的宽描边)和流光(一小段高亮沿路径跑)**只在悬停/选中态渲染**:blur 是 filter,成本随线条数量线性上升,常态几十条线全开会明显掉帧。常态仍是原来那条极淡的 虚线,只有指向的那条亮起来,顺带让「当前在看哪条链路」更清楚。 流光周期 2.1s 与虚线流动 1.25s 刻意错拍 —— 同频会锁成一条整体平移的虚线, 看不出有光在走。keyframes 的位移量等于 dasharray 之和(16 + 118 = 134), 否则循环处会跳一帧。 新动画已加入全部三处禁用名单:prefers-reduced-motion、.no-motion,以及 [data-canvas-viewport-interacting](平移缩放期间)。第三处最关键 —— 漏掉的话拖画布时 几十条线还在跑动画,正是错题本里「画布高频重绘导致崩溃」那一类。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -42,7 +42,26 @@ export const ConnectionPath = React.memo(function ConnectionPath({
|
||||
<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,成本随线条数量线性上升,
|
||||
常态几十条线全开会明显掉帧,所以刻意只给悬停/选中的那一条。
|
||||
垫在底衬描边之下,不改动常态可读性那几层。 */}
|
||||
{emphasized ? <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}
|
||||
@@ -102,37 +121,58 @@ export const ConnectionPath = React.memo(function ConnectionPath({
|
||||
strokeLinejoin="round"
|
||||
style={{ pointerEvents: "none" }}
|
||||
/> : null}
|
||||
{/* 流光:一小段高亮沿路径跑。周期与虚线流动刻意不同(2.1s vs 1.25s),
|
||||
两者错拍才像有光在走;同频会锁成一条整体平移的虚线。 */}
|
||||
{emphasized ? <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.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));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user