feat(canvas): 节点连接 - 新增外置标题与跟随锚点快速连线
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type CanvasLeaferGraphicsLayerProps = {
|
||||
connectingParams: ConnectionHandle | null;
|
||||
mouseWorld: Position;
|
||||
connectionTargetNodeId: string | null;
|
||||
connectionTargetAnchorRatio?: number;
|
||||
nodeById: Map<string, CanvasNodeData>;
|
||||
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",
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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<HTMLTextAreaElement>(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 (
|
||||
<div
|
||||
data-node-id={data.id}
|
||||
@@ -283,6 +300,19 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
}}
|
||||
onContextMenu={(event) => onContextMenu(event, data.id)}
|
||||
>
|
||||
<NodeExternalHeader
|
||||
node={data}
|
||||
scale={scale}
|
||||
active={hovered || isSelected || isFocusRelated}
|
||||
editable={!readOnly && !data.metadata?.locked && Boolean(onTitleChange)}
|
||||
editing={isEditingTitle}
|
||||
draft={titleDraft}
|
||||
theme={theme}
|
||||
onDraftChange={setTitleDraft}
|
||||
onEdit={() => setIsEditingTitle(true)}
|
||||
onCommit={commitTitle}
|
||||
onCancel={() => { setTitleDraft(data.title); setIsEditingTitle(false); }}
|
||||
/>
|
||||
<CometCard
|
||||
containerClassName="overflow-visible"
|
||||
className={`canvas-node-shell relative h-full w-full overflow-visible rounded-[18px] ${flushMediaContent ? "border-0" : "border"} ${isGeneratingNode ? "canvas-node-shell-generating" : ""}`}
|
||||
@@ -449,8 +479,8 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
</> : null}
|
||||
</CometCard>
|
||||
|
||||
{!readOnly && data.type !== CanvasNodeType.Script ? <ConnectionHandleDot side="left" scale={scale} visible={hovered || isSelected || isConnecting} theme={theme} onPointerDown={(event) => onConnectStart(event, data.id, "target")} /> : null}
|
||||
{!readOnly && data.type !== CanvasNodeType.Script ? <ConnectionHandleDot side="right" scale={scale} visible={data.type !== CanvasNodeType.Config && (hovered || isSelected || isConnecting)} theme={theme} onPointerDown={(event) => onConnectStart(event, data.id, "source")} /> : null}
|
||||
{!readOnly && data.type !== CanvasNodeType.Script ? <ConnectionSideRail side="left" scale={scale} visible={hovered} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
|
||||
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config ? <ConnectionSideRail side="right" scale={scale} visible={hovered} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
@@ -1089,20 +1119,120 @@ function ResizeHandle({ corner, onMouseDown }: { corner: ResizeCorner; onMouseDo
|
||||
return <div className={`absolute z-50 size-7 ${positionClass}`} onMouseDown={(event) => 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 (
|
||||
<div
|
||||
className={`canvas-connection-handle absolute top-1/2 z-30 flex -translate-y-1/2 cursor-pointer items-center justify-center transition-opacity duration-150 ${
|
||||
side === "left" ? "left-0 -translate-x-1/2" : "right-0 translate-x-1/2"
|
||||
} ${visible ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
|
||||
style={{ width: 40 * inverseScale, height: 40 * inverseScale }}
|
||||
onPointerDown={onPointerDown}
|
||||
className="absolute left-0 z-40 flex h-6 max-w-[240px] items-center gap-1"
|
||||
style={{ top: -24 * inverseScale, color: active ? theme.node.text : theme.node.label, transform: `scale(${inverseScale})`, transformOrigin: "left top" }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="canvas-node-tool-button grid place-items-center rounded-full border" style={{ width: 16 * inverseScale, height: 16 * inverseScale, borderWidth: inverseScale, background: theme.node.panel, borderColor: theme.accent.primary }}>
|
||||
<span className="block rounded-full" style={{ width: 6 * inverseScale, height: 6 * inverseScale, background: theme.accent.primary }} />
|
||||
</div>
|
||||
<Icon className="size-3 shrink-0" strokeWidth={1.8} />
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
className="h-6 min-w-20 max-w-[190px] rounded border bg-transparent px-1.5 text-xs font-medium outline-none"
|
||||
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 10px 28px ${theme.spatial.shadow}, inset 0 1px 0 rgba(255,255,255,.06)` }}
|
||||
onChange={(event) => 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 ? (
|
||||
<button type="button" className="group flex min-w-0 items-center gap-1 rounded px-0.5 text-xs font-medium outline-none transition-opacity hover:opacity-100 focus-visible:ring-1" style={{ opacity: active ? 1 : 0.78, "--tw-ring-color": theme.node.activeStroke } as React.CSSProperties} onClick={onEdit} aria-label={`编辑节点名称:${node.title}`}>
|
||||
<span className="max-w-[190px] truncate">{node.title}</span>
|
||||
<Pencil className="size-2.5 shrink-0 opacity-55 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="max-w-[210px] truncate text-xs font-medium" style={{ opacity: active ? 1 : 0.78 }}>{node.title}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<HTMLSpanElement>(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<HTMLButtonElement>) => {
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
className={`group absolute top-1/2 z-30 touch-none -translate-y-1/2 outline-none transition-opacity duration-150 ${visible ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
|
||||
style={{ width: 56 * inverseScale, height: `min(100%, ${72 * inverseScale}px)`, ...(side === "left" ? { right: "100%" } : { left: "100%" }) }}
|
||||
onPointerEnter={updateAnchor}
|
||||
onPointerMove={updateAnchor}
|
||||
onPointerLeave={resetAnchor}
|
||||
onPointerDown={(event) => onPointerDown(event, anchorRatioRef.current)}
|
||||
aria-label={`${side === "left" ? "输入" : "输出"}连接点,单击创建节点或拖动连线`}
|
||||
>
|
||||
<span
|
||||
ref={handleRef}
|
||||
className="absolute grid -translate-y-1/2 place-items-center rounded-full border transition-[background-color,box-shadow] duration-150 group-hover:brightness-125 group-focus-visible:brightness-125"
|
||||
style={{
|
||||
top: "50%",
|
||||
width: 18 * inverseScale,
|
||||
height: 18 * inverseScale,
|
||||
...(side === "left" ? { right: 6 * inverseScale } : { left: 6 * inverseScale }),
|
||||
borderWidth: inverseScale,
|
||||
background: theme.spatial.elevated,
|
||||
borderColor: theme.node.activeStroke,
|
||||
color: theme.node.activeStroke,
|
||||
boxShadow: `0 4px 12px ${theme.spatial.shadow}`,
|
||||
}}
|
||||
>
|
||||
<Plus style={{ width: 10 * inverseScale, height: 10 * inverseScale }} strokeWidth={2} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement | null>; containerRef: RefObject<HTMLDivElement | null>; count: number; children: ReactNode }) {
|
||||
|
||||
@@ -104,13 +104,17 @@ export function applyNodeConfigPatch(node: CanvasNodeData, patch: Partial<Canvas
|
||||
return size && (node.type === CanvasNodeType.Image || node.type === CanvasNodeType.Video) ? { ...next, ...size, position: { x: node.position.x + node.width / 2 - size.width / 2, y: node.position.y + node.height / 2 - size.height / 2 } } : next;
|
||||
}
|
||||
|
||||
export function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle, handleId?: string, scrollTop = 0) {
|
||||
export function getConnectionTargetAnchor(node: CanvasNodeData, current: ConnectionHandle, handleId?: string, scrollTop = 0, anchorRatio?: number) {
|
||||
return {
|
||||
x: current.handleType === "source" ? node.position.x : node.position.x + node.width,
|
||||
y: storyboardHandleY(node, handleId, scrollTop) ?? node.position.y + node.height / 2,
|
||||
y: storyboardHandleY(node, handleId, scrollTop) ?? node.position.y + node.height * normalizeAnchorRatio(anchorRatio),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -48,7 +48,7 @@ type CanvasProjectWorldLayersProps = {
|
||||
onNodeMouseDown: (event: ReactMouseEvent, nodeId: string) => 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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<ConnectionHandle | null>(null);
|
||||
const [connectionTargetNodeId, setConnectionTargetNodeId] = useState<string | null>(null);
|
||||
const [connectionTargetAnchorRatio, setConnectionTargetAnchorRatio] = useState<number | undefined>();
|
||||
const [pendingConnectionCreate, setPendingConnectionCreate] = useState<PendingConnectionCreate | null>(null);
|
||||
const [mouseWorld, setMouseWorld] = useState<Position>({ x: 0, y: 0 });
|
||||
const connectingParamsRef = useRef(connectingParams);
|
||||
const connectingPointerIdRef = useRef<number | null>(null);
|
||||
const connectingPointerStartRef = useRef<Position | null>(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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user