Add canvas workflow and harden data import

This commit is contained in:
FengLee
2026-05-09 23:54:18 +08:00
parent 1a0607fe8d
commit 24be9c550b
15 changed files with 3257 additions and 4 deletions

File diff suppressed because it is too large Load Diff

View 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>
);
}