Add canvas workflow and harden data import
This commit is contained in:
2137
src/components/canvas/infinite-canvas-workspace.tsx
Executable file
2137
src/components/canvas/infinite-canvas-workspace.tsx
Executable file
File diff suppressed because it is too large
Load Diff
553
src/components/canvas/react-flow-canvas.tsx
Executable file
553
src/components/canvas/react-flow-canvas.tsx
Executable file
@@ -0,0 +1,553 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Background,
|
||||
ConnectionLineType,
|
||||
Controls,
|
||||
Handle,
|
||||
MarkerType,
|
||||
MiniMap,
|
||||
Position,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
useReactFlow,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type EdgeChange,
|
||||
type Node as FlowNode,
|
||||
type NodeChange,
|
||||
type NodeProps,
|
||||
type Rect,
|
||||
type ReactFlowInstance,
|
||||
type Viewport as FlowViewport,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { Brush, FileImage, Image as ImageIcon, Layers, Link2, Loader2, Maximize2, Move, Type } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CanvasConnection, CanvasLayer, CanvasNode, CanvasViewport } from '@/lib/canvas-types';
|
||||
|
||||
type NodePositionCommit = { id: string; x: number; y: number };
|
||||
|
||||
type CanvasFlowNodeData = {
|
||||
node: CanvasNode;
|
||||
selected: boolean;
|
||||
connecting: boolean;
|
||||
connections: CanvasConnection[];
|
||||
allNodes: CanvasNode[];
|
||||
layerCanvasSize: number;
|
||||
layerColors: Record<CanvasLayer['type'], string>;
|
||||
onSelect: (id: string, additive: boolean) => void;
|
||||
onStartConnect: (id: string) => void;
|
||||
onRemoveConnection: (id: string) => void;
|
||||
};
|
||||
|
||||
type CanvasFlowNode = FlowNode<CanvasFlowNodeData, 'canvasNode'>;
|
||||
|
||||
type ReactFlowCanvasProps = {
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
viewport: CanvasViewport;
|
||||
selectedNodeIds: string[];
|
||||
connectingFromId: string | null;
|
||||
editable: boolean;
|
||||
minZoom: number;
|
||||
maxZoom: number;
|
||||
layerCanvasSize: number;
|
||||
layerColors: Record<CanvasLayer['type'], string>;
|
||||
onSelectNode: (id: string, additive: boolean) => void;
|
||||
onSelectionChange: (ids: string[]) => void;
|
||||
onStartConnect: (id: string) => void;
|
||||
onConnect: (sourceId: string, targetId: string) => void;
|
||||
onRemoveConnection: (id: string) => void;
|
||||
onRemoveConnections: (ids: string[]) => void;
|
||||
onNodesCommit: (positions: NodePositionCommit[], options?: { history?: boolean; dirty?: boolean }) => void;
|
||||
onViewportCommit: (viewport: CanvasViewport) => void;
|
||||
onReady?: (controls: CanvasFlowControls | null) => void;
|
||||
onPaneClick: (point: { x: number; y: number }, event: MouseEvent | React.MouseEvent) => void;
|
||||
onPaneDoubleClick: (point: { x: number; y: number }) => void;
|
||||
};
|
||||
|
||||
export type CanvasFlowControls = {
|
||||
setViewport: (viewport: CanvasViewport) => Promise<boolean>;
|
||||
zoomTo: (zoom: number) => Promise<boolean>;
|
||||
fitBounds: (bounds: Rect, options?: { padding?: number; duration?: number }) => Promise<boolean>;
|
||||
getViewport: () => CanvasViewport;
|
||||
};
|
||||
|
||||
const CONNECTION_COLORS = ['#22c55e', '#06b6d4', '#8b5cf6', '#f59e0b', '#ef4444', '#14b8a6'];
|
||||
const SNAP_GRID: [number, number] = [20, 20];
|
||||
const MULTI_SELECTION_KEYS = ['Meta', 'Control', 'Shift'];
|
||||
const FLOW_FIT_VIEW_OPTIONS = { padding: 0.2 };
|
||||
const CANVAS_NODE_TYPES = { canvasNode: CanvasNodeCard };
|
||||
|
||||
function getNodeImageUrl(node?: CanvasNode | null) {
|
||||
if (!node) return '';
|
||||
if (node.type === 'image') return node.imageUrl || '';
|
||||
return node.selectedOutput || node.outputImages?.[0] || '';
|
||||
}
|
||||
|
||||
function CanvasNodeCard({ data }: NodeProps<CanvasFlowNode>) {
|
||||
const { node, selected, connecting, connections, allNodes, layerCanvasSize, layerColors, onSelect, onStartConnect, onRemoveConnection } = data;
|
||||
const incomingImage = allNodes.find(item => connections.some(connection => connection.targetNodeId === node.id && connection.sourceNodeId === item.id && !!getNodeImageUrl(item)));
|
||||
const nodeConnections = connections.filter(connection => connection.sourceNodeId === node.id || connection.targetNodeId === node.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-canvas-node
|
||||
data-node-id={node.id}
|
||||
className={cn(
|
||||
'group h-full overflow-visible rounded-lg border transition-shadow',
|
||||
node.type === 'frame' ? 'bg-background/20 shadow-none' : 'bg-card shadow-sm',
|
||||
selected ? 'border-primary ring-2 ring-primary/20' : connecting ? 'border-emerald-500 ring-2 ring-emerald-500/20' : 'border-border',
|
||||
)}
|
||||
style={{ borderColor: node.type === 'frame' && !selected ? node.color || '#22c55e' : undefined }}
|
||||
onPointerDown={(event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.closest('button,input,textarea,[role="combobox"],.nodrag')) return;
|
||||
onSelect(node.id, event.ctrlKey || event.metaKey || event.shiftKey);
|
||||
}}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} id="input" className="!h-3 !w-3 !border-2 !border-background !bg-primary" />
|
||||
<Handle type="source" position={Position.Right} id="output" className="!h-3 !w-3 !border-2 !border-background !bg-primary" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'nodrag absolute right-0 top-1/2 z-20 flex h-7 w-7 -translate-y-1/2 translate-x-1/2 items-center justify-center rounded-full border bg-background text-muted-foreground opacity-0 shadow-sm transition-all hover:border-primary hover:text-primary group-hover:opacity-100',
|
||||
connecting ? 'border-emerald-500 bg-emerald-500 text-white opacity-100' : '',
|
||||
)}
|
||||
title="从此模块开始连线"
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onStartConnect(node.id);
|
||||
}}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<div className="h-full overflow-hidden rounded-lg">
|
||||
<div className="flex h-10 items-center justify-between border-b bg-muted/40 px-3">
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm font-medium">
|
||||
{node.type === 'text' ? <Type className="h-4 w-4" /> : null}
|
||||
{node.type === 'image' ? <ImageIcon className="h-4 w-4" /> : null}
|
||||
{node.type === 'text2img' ? <Brush className="h-4 w-4" /> : null}
|
||||
{node.type === 'img2img' ? <FileImage className="h-4 w-4" /> : null}
|
||||
{node.type === 'layeredImage' ? <Layers className="h-4 w-4" /> : null}
|
||||
{node.type === 'frame' ? <Maximize2 className="h-4 w-4" /> : null}
|
||||
<span className="truncate">{node.title}</span>
|
||||
</div>
|
||||
{node.status === 'generating' ? <Loader2 className="h-4 w-4 animate-spin text-primary" /> : <Move className="h-4 w-4 text-muted-foreground" />}
|
||||
</div>
|
||||
|
||||
<div className="h-[calc(100%-2.5rem)] p-3">
|
||||
{node.type === 'text' ? (
|
||||
<div className="h-full whitespace-pre-wrap rounded-md bg-muted/30 p-3 text-sm leading-6">{node.text}</div>
|
||||
) : null}
|
||||
|
||||
{node.type === 'image' ? (
|
||||
<div className="h-full overflow-hidden rounded-md bg-muted">
|
||||
{node.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={node.imageUrl} alt={node.title} className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">未选择图片</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{node.type === 'text2img' || node.type === 'img2img' ? (
|
||||
<div className="flex h-full flex-col gap-3">
|
||||
<div className="line-clamp-3 min-h-14 rounded-md bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||
{node.prompt || (connections.some(connection => connection.targetNodeId === node.id) ? '已连接上游内容,可继续补充描述' : '在右侧输入创作描述')}
|
||||
</div>
|
||||
{node.type === 'img2img' ? (
|
||||
<div className="h-20 overflow-hidden rounded-md border bg-muted">
|
||||
{node.referenceImage || getNodeImageUrl(incomingImage) ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={node.referenceImage || getNodeImageUrl(incomingImage)} alt="参考图" className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">从右侧选择参考图或连接图片节点</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-hidden rounded-md border bg-muted">
|
||||
{node.selectedOutput ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={node.selectedOutput} alt="生成结果" className="h-full w-full object-contain" />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
{node.status === 'failed' ? node.error || '生成失败' : node.status === 'generating' ? '生成中...' : '等待生成'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{node.type === 'layeredImage' ? (
|
||||
<div className="grid h-full grid-cols-[1fr_120px] gap-3 rounded-md border bg-muted/20 p-3">
|
||||
<div className="relative overflow-hidden rounded-md border bg-background">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(45deg,hsl(var(--muted))_25%,transparent_25%),linear-gradient(-45deg,hsl(var(--muted))_25%,transparent_25%),linear-gradient(45deg,transparent_75%,hsl(var(--muted))_75%),linear-gradient(-45deg,transparent_75%,hsl(var(--muted))_75%)] bg-[length:24px_24px] bg-[position:0_0,0_12px,12px_-12px,-12px_0px] opacity-35" />
|
||||
{(node.layers || []).filter(layer => layer.visible).map(layer => (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={cn(
|
||||
'absolute border border-white/60 shadow-sm',
|
||||
layer.type === 'text' ? 'flex items-center px-2 text-xs font-semibold' : 'rounded-md',
|
||||
layer.locked ? 'outline outline-1 outline-dashed outline-muted-foreground/70' : '',
|
||||
)}
|
||||
style={{
|
||||
left: `${(layer.x / layerCanvasSize) * 100}%`,
|
||||
top: `${(layer.y / layerCanvasSize) * 100}%`,
|
||||
width: `${(layer.width / layerCanvasSize) * 100}%`,
|
||||
height: `${(layer.height / layerCanvasSize) * 100}%`,
|
||||
backgroundColor: layer.type === 'text' ? 'transparent' : layer.color || layerColors[layer.type],
|
||||
color: layer.color || layerColors.text,
|
||||
opacity: layer.opacity ?? 1,
|
||||
}}
|
||||
>
|
||||
{layer.type === 'text' ? layer.text || layer.name : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-w-0 space-y-1 overflow-y-auto">
|
||||
{(node.layers || []).slice().reverse().map(layer => (
|
||||
<div key={layer.id} className="truncate rounded-md border bg-background px-2 py-1 text-[11px]">
|
||||
<span className={layer.visible ? '' : 'text-muted-foreground line-through'}>{layer.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{node.type === 'frame' ? (
|
||||
<div className="flex h-full flex-col justify-end rounded-md border border-dashed bg-background/25 p-3 text-xs text-muted-foreground" style={{ borderColor: node.color || '#22c55e' }}>
|
||||
<div className="rounded-md bg-background/80 px-3 py-2 shadow-sm">{node.text || '流程分组'}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selected && nodeConnections.length > 0 ? (
|
||||
<div className="nodrag absolute left-3 top-full z-30 mt-2 w-64 rounded-lg border bg-background/95 p-2 text-xs shadow-lg backdrop-blur">
|
||||
<div className="mb-1 font-medium text-muted-foreground">相关连线</div>
|
||||
<div className="space-y-1">
|
||||
{nodeConnections.slice(0, 4).map(connection => {
|
||||
const source = allNodes.find(item => item.id === connection.sourceNodeId);
|
||||
const target = allNodes.find(item => item.id === connection.targetNodeId);
|
||||
return (
|
||||
<div key={connection.id} className="flex items-center justify-between gap-2 rounded-md border px-2 py-1">
|
||||
<span className="min-w-0 truncate">{source?.title || '未知模块'} → {target?.title || '未知模块'}</span>
|
||||
<button className="text-destructive hover:underline" onClick={() => onRemoveConnection(connection.id)}>删</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sameFlowNodePositions(a: CanvasFlowNode[], b: CanvasFlowNode[]) {
|
||||
if (a.length !== b.length) return false;
|
||||
const bMap = new Map(b.map(node => [node.id, node]));
|
||||
return a.every(node => {
|
||||
const next = bMap.get(node.id);
|
||||
return !!next && node.position.x === next.position.x && node.position.y === next.position.y;
|
||||
});
|
||||
}
|
||||
|
||||
function sameFlowNodes(a: CanvasFlowNode[], b: CanvasFlowNode[]) {
|
||||
if (a.length !== b.length) return false;
|
||||
const bMap = new Map(b.map(node => [node.id, node]));
|
||||
return a.every(node => {
|
||||
const next = bMap.get(node.id);
|
||||
return !!next
|
||||
&& node.position.x === next.position.x
|
||||
&& node.position.y === next.position.y
|
||||
&& node.selected === next.selected
|
||||
&& node.draggable === next.draggable
|
||||
&& node.selectable === next.selectable
|
||||
&& node.width === next.width
|
||||
&& node.height === next.height
|
||||
&& node.data.node === next.data.node
|
||||
&& node.data.selected === next.data.selected
|
||||
&& node.data.connecting === next.data.connecting
|
||||
&& node.data.connections === next.data.connections
|
||||
&& node.data.allNodes === next.data.allNodes;
|
||||
});
|
||||
}
|
||||
|
||||
function clampViewport(viewport: FlowViewport, minZoom: number, maxZoom: number): CanvasViewport {
|
||||
return {
|
||||
x: viewport.x,
|
||||
y: viewport.y,
|
||||
zoom: Math.min(maxZoom, Math.max(minZoom, viewport.zoom)),
|
||||
};
|
||||
}
|
||||
|
||||
function FlowCanvasInner({
|
||||
nodes,
|
||||
connections,
|
||||
viewport,
|
||||
selectedNodeIds,
|
||||
connectingFromId,
|
||||
editable,
|
||||
minZoom,
|
||||
maxZoom,
|
||||
layerCanvasSize,
|
||||
layerColors,
|
||||
onSelectNode,
|
||||
onSelectionChange,
|
||||
onStartConnect,
|
||||
onConnect,
|
||||
onRemoveConnection,
|
||||
onRemoveConnections,
|
||||
onNodesCommit,
|
||||
onViewportCommit,
|
||||
onReady,
|
||||
onPaneClick,
|
||||
onPaneDoubleClick,
|
||||
}: ReactFlowCanvasProps) {
|
||||
const reactFlow = useReactFlow<CanvasFlowNode, Edge>();
|
||||
const draggingRef = useRef(false);
|
||||
const movingRef = useRef(false);
|
||||
const paneClickTimerRef = useRef<number | null>(null);
|
||||
|
||||
const incomingNodes = useMemo<CanvasFlowNode[]>(() => nodes.map(node => ({
|
||||
id: node.id,
|
||||
type: 'canvasNode',
|
||||
position: { x: node.x, y: node.y },
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
selected: selectedNodeIds.includes(node.id),
|
||||
draggable: editable,
|
||||
selectable: editable,
|
||||
zIndex: node.type === 'frame' ? Math.min(node.zIndex, 0) : node.zIndex,
|
||||
data: {
|
||||
node,
|
||||
selected: selectedNodeIds.includes(node.id),
|
||||
connecting: connectingFromId === node.id,
|
||||
connections,
|
||||
allNodes: nodes,
|
||||
layerCanvasSize,
|
||||
layerColors,
|
||||
onSelect: onSelectNode,
|
||||
onStartConnect,
|
||||
onRemoveConnection,
|
||||
},
|
||||
style: {
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
},
|
||||
})), [connectingFromId, connections, editable, layerCanvasSize, layerColors, nodes, onRemoveConnection, onSelectNode, onStartConnect, selectedNodeIds]);
|
||||
|
||||
const [flowNodes, setFlowNodes] = useState<CanvasFlowNode[]>(incomingNodes);
|
||||
|
||||
const flowEdges = useMemo<Edge[]>(() => connections.map((connection, index) => ({
|
||||
id: connection.id,
|
||||
source: connection.sourceNodeId,
|
||||
target: connection.targetNodeId,
|
||||
sourceHandle: 'output',
|
||||
targetHandle: 'input',
|
||||
label: connection.label,
|
||||
type: 'smoothstep',
|
||||
markerEnd: { type: MarkerType.ArrowClosed },
|
||||
style: {
|
||||
stroke: CONNECTION_COLORS[index % CONNECTION_COLORS.length],
|
||||
strokeWidth: 2.5,
|
||||
},
|
||||
labelBgStyle: { fill: 'hsl(var(--background))', fillOpacity: 0.9 },
|
||||
labelStyle: { fill: 'hsl(var(--muted-foreground))', fontSize: 12 },
|
||||
})), [connections]);
|
||||
|
||||
useEffect(() => {
|
||||
setFlowNodes(current => {
|
||||
if (draggingRef.current && sameFlowNodePositions(current, incomingNodes)) return current;
|
||||
if (sameFlowNodes(current, incomingNodes)) return current;
|
||||
return incomingNodes;
|
||||
});
|
||||
}, [incomingNodes]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (paneClickTimerRef.current !== null) {
|
||||
window.clearTimeout(paneClickTimerRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
onReady?.(null);
|
||||
}, [onReady]);
|
||||
|
||||
const commitCurrentNodePositions = useCallback((options?: { history?: boolean; dirty?: boolean }) => {
|
||||
const positions = reactFlow.getNodes().map(node => ({
|
||||
id: node.id,
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
}));
|
||||
onNodesCommit(positions, options);
|
||||
}, [onNodesCommit, reactFlow]);
|
||||
|
||||
const handleNodesChange = useCallback((changes: NodeChange<CanvasFlowNode>[]) => {
|
||||
const relevantChanges = changes.filter(change => change.type === 'position' || change.type === 'select' || change.type === 'remove');
|
||||
if (relevantChanges.length === 0) return;
|
||||
|
||||
setFlowNodes(current => {
|
||||
const removedIds = new Set(
|
||||
relevantChanges
|
||||
.filter((change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'remove' }> => change.type === 'remove')
|
||||
.map(change => change.id),
|
||||
);
|
||||
let changed = removedIds.size > 0;
|
||||
const nextNodes = current.map(node => {
|
||||
if (removedIds.has(node.id)) return node;
|
||||
const positionChange = relevantChanges.find(
|
||||
(change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'position' }> => change.type === 'position' && change.id === node.id && !!change.position,
|
||||
);
|
||||
const selectChange = relevantChanges.find(
|
||||
(change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'select' }> => change.type === 'select' && change.id === node.id,
|
||||
);
|
||||
const nextPosition = positionChange?.position || node.position;
|
||||
const nextSelected = typeof selectChange?.selected === 'boolean' ? selectChange.selected : node.selected;
|
||||
if (nextPosition === node.position && nextSelected === node.selected) return node;
|
||||
changed = true;
|
||||
return {
|
||||
...node,
|
||||
position: nextPosition,
|
||||
selected: nextSelected,
|
||||
};
|
||||
}).filter(node => !removedIds.has(node.id));
|
||||
return changed ? nextNodes : current;
|
||||
});
|
||||
|
||||
const selected = relevantChanges
|
||||
.filter((change): change is Extract<NodeChange<CanvasFlowNode>, { type: 'select' }> => change.type === 'select' && change.selected)
|
||||
.map(change => change.id);
|
||||
if (selected.length > 0) onSelectionChange(selected);
|
||||
}, [onSelectionChange]);
|
||||
|
||||
const handleEdgesChange = useCallback((changes: EdgeChange<Edge>[]) => {
|
||||
const removedIds = changes.filter(change => change.type === 'remove').map(change => change.id);
|
||||
if (removedIds.length > 0) onRemoveConnections(removedIds);
|
||||
}, [onRemoveConnections]);
|
||||
|
||||
const handleConnect = useCallback((connection: Connection) => {
|
||||
if (!connection.source || !connection.target) return;
|
||||
onConnect(connection.source, connection.target);
|
||||
}, [onConnect]);
|
||||
|
||||
const handleMoveEnd = useCallback((_event: MouseEvent | TouchEvent | null, nextViewport: FlowViewport) => {
|
||||
if (!movingRef.current) return;
|
||||
movingRef.current = false;
|
||||
const committedViewport = clampViewport(nextViewport, minZoom, maxZoom);
|
||||
onViewportCommit(committedViewport);
|
||||
}, [maxZoom, minZoom, onViewportCommit]);
|
||||
|
||||
const handleNodeDragStart = useCallback(() => {
|
||||
draggingRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handleNodeDragStop = useCallback(() => {
|
||||
draggingRef.current = false;
|
||||
commitCurrentNodePositions({ history: true, dirty: true });
|
||||
}, [commitCurrentNodePositions]);
|
||||
|
||||
const handleMoveStart = useCallback(() => {
|
||||
movingRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handlePaneReactFlowClick = useCallback((event: React.MouseEvent) => {
|
||||
const point = reactFlow.screenToFlowPosition({ x: event.clientX, y: event.clientY });
|
||||
if (event.detail >= 2) {
|
||||
if (paneClickTimerRef.current !== null) {
|
||||
window.clearTimeout(paneClickTimerRef.current);
|
||||
paneClickTimerRef.current = null;
|
||||
}
|
||||
onPaneDoubleClick(reactFlow.screenToFlowPosition({ x: event.clientX, y: event.clientY }, { snapToGrid: true, snapGrid: SNAP_GRID }));
|
||||
return;
|
||||
}
|
||||
if (paneClickTimerRef.current !== null) {
|
||||
window.clearTimeout(paneClickTimerRef.current);
|
||||
}
|
||||
paneClickTimerRef.current = window.setTimeout(() => {
|
||||
paneClickTimerRef.current = null;
|
||||
onPaneClick(point, event);
|
||||
}, 180);
|
||||
}, [onPaneClick, onPaneDoubleClick, reactFlow]);
|
||||
|
||||
const handleSelectionChange = useCallback(({ nodes: selectedNodes }: { nodes: CanvasFlowNode[] }) => {
|
||||
onSelectionChange(selectedNodes.map(node => node.id));
|
||||
}, [onSelectionChange]);
|
||||
|
||||
const getMiniMapNodeColor = useCallback((node: FlowNode) => (
|
||||
selectedNodeIds.includes(node.id) ? 'hsl(var(--primary))' : 'hsl(var(--muted-foreground))'
|
||||
), [selectedNodeIds]);
|
||||
|
||||
const handleInit = useCallback((instance: ReactFlowInstance<CanvasFlowNode, Edge>) => {
|
||||
onReady?.({
|
||||
setViewport: (nextViewport) => instance.setViewport(nextViewport, { duration: 120 }),
|
||||
zoomTo: (zoom) => instance.zoomTo(Math.min(maxZoom, Math.max(minZoom, zoom)), { duration: 120 }),
|
||||
fitBounds: (bounds, options) => instance.fitBounds(bounds, options),
|
||||
getViewport: () => clampViewport(instance.getViewport(), minZoom, maxZoom),
|
||||
});
|
||||
}, [maxZoom, minZoom, onReady]);
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
className="canvas-flow"
|
||||
nodes={flowNodes}
|
||||
edges={flowEdges}
|
||||
nodeTypes={CANVAS_NODE_TYPES}
|
||||
minZoom={minZoom}
|
||||
maxZoom={maxZoom}
|
||||
snapToGrid
|
||||
snapGrid={SNAP_GRID}
|
||||
defaultViewport={viewport}
|
||||
zoomOnScroll
|
||||
zoomOnPinch
|
||||
panOnScroll={false}
|
||||
panOnDrag
|
||||
selectionOnDrag={false}
|
||||
multiSelectionKeyCode={MULTI_SELECTION_KEYS}
|
||||
deleteKeyCode={null}
|
||||
zoomOnDoubleClick={false}
|
||||
nodesDraggable={editable}
|
||||
nodesConnectable={editable}
|
||||
nodesFocusable={false}
|
||||
edgesFocusable={false}
|
||||
connectionLineType={ConnectionLineType.SmoothStep}
|
||||
fitViewOptions={FLOW_FIT_VIEW_OPTIONS}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={handleConnect}
|
||||
onNodeDragStart={handleNodeDragStart}
|
||||
onNodeDragStop={handleNodeDragStop}
|
||||
onMoveStart={handleMoveStart}
|
||||
onMoveEnd={handleMoveEnd}
|
||||
onPaneClick={handlePaneReactFlowClick}
|
||||
onSelectionChange={handleSelectionChange}
|
||||
onInit={handleInit}
|
||||
>
|
||||
<Background gap={20} size={1} color="hsl(var(--border))" />
|
||||
<Background gap={100} size={1.2} color="hsl(var(--muted-foreground))" />
|
||||
<Controls showInteractive={false} position="bottom-left" />
|
||||
<MiniMap
|
||||
position="bottom-right"
|
||||
pannable
|
||||
zoomable
|
||||
nodeColor={getMiniMapNodeColor}
|
||||
maskColor="hsl(var(--background) / 0.58)"
|
||||
/>
|
||||
</ReactFlow>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReactFlowCanvas(props: ReactFlowCanvasProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<FlowCanvasInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user