Files
ttstd-infinite-canvas/web/src/pages/canvas/use-canvas-selection-controller.ts
WindC0X 9ee71d4650 feat(canvas): 画布媒体工作流 - 支持批量连线、失败重试与模型能力增强
合并 PR #230。保留当前主线样式与组件结构,吸收批量连线、失败批次重试、媒体模型能力和相关后端增强,并修复集成检查发现的分辨率选择器启用条件与测试稳定性问题。
2026-08-15 15:48:16 +08:00

346 lines
18 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type Dispatch, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type RefObject, type SetStateAction } from "react";
import { applyCanvasSelectionPreview } from "@/lib/canvas/canvas-live-viewport";
import { calculateNodeAlignment, createNodeAlignmentContext, isHiddenBatchChild, sameStringSet, type NodeAlignmentContext } from "@/lib/canvas/canvas-project-domain";
import { applyFrameDrop, findFrameDropTarget, getFrameChildIds, isFrameNode, isNodeHiddenByCollapsedFrame } from "@/lib/canvas/canvas-frame";
import type { CanvasNodeData, Position, SelectionBox, ViewportTransform } from "@/types/canvas";
type UseCanvasSelectionControllerOptions = {
containerRef: RefObject<HTMLDivElement | null>;
nodesRef: { current: CanvasNodeData[] };
viewportRef: { current: ViewportTransform };
selectedNodeIdsRef: { current: Set<string> };
historyPausedRef: { current: boolean };
screenToCanvas: (clientX: number, clientY: number) => Position;
setNodes: Dispatch<SetStateAction<CanvasNodeData[]>>;
setSelectedNodeIds: Dispatch<SetStateAction<Set<string>>>;
setSelectedConnectionId: Dispatch<SetStateAction<string | null>>;
cancelPendingConnectionCreate: () => void;
onCanvasSelectionStart: () => void;
onNodeInteractionStart: (selectionModifier: boolean) => void;
onNodeClick: (node: CanvasNodeData) => void;
onBatchConnectionTarget?: (event: ReactMouseEvent, nodeId: string) => boolean;
onDeselect: () => void;
onSelectionBoxEnd?: () => void;
};
type DragState = {
isDraggingNode: boolean;
hasMoved: boolean;
openPanelOnClick: boolean;
startX: number;
startY: number;
draggedNodeIds: string[];
initialSelectedNodes: Array<{ id: string; x: number; y: number }>;
};
const EMPTY_DRAG_STATE: DragState = {
isDraggingNode: false,
hasMoved: false,
openPanelOnClick: true,
startX: 0,
startY: 0,
draggedNodeIds: [],
initialSelectedNodes: [],
};
export function useCanvasSelectionController({
containerRef,
nodesRef,
viewportRef,
selectedNodeIdsRef,
historyPausedRef,
screenToCanvas,
setNodes,
setSelectedNodeIds,
setSelectedConnectionId,
cancelPendingConnectionCreate,
onCanvasSelectionStart,
onNodeInteractionStart,
onNodeClick,
onBatchConnectionTarget,
onDeselect,
onSelectionBoxEnd,
}: UseCanvasSelectionControllerOptions) {
const dragFrameRef = useRef<number | null>(null);
const pendingNodeDragRef = useRef<Position>({ x: 0, y: 0 });
const pendingAlignmentGuidesRef = useRef<{ vertical?: number; horizontal?: number }>({});
const alignmentContextRef = useRef<NodeAlignmentContext | null>(null);
const lastFrameDropCheckRef = useRef(0);
const selectionFrameRef = useRef<number | null>(null);
const selectionBoundsElementRef = useRef<HTMLDivElement>(null);
const selectionCandidatesRef = useRef<Array<{ id: string; left: number; top: number; right: number; bottom: number }>>([]);
const pendingSelectionPointRef = useRef<Position | null>(null);
const selectionActivatedRef = useRef(false);
const selectionBoxRef = useRef<SelectionBox | null>(null);
const nodeDraggingRef = useRef(false);
const dragRef = useRef<DragState>({ ...EMPTY_DRAG_STATE });
const [selectionBox, setSelectionBox] = useState<SelectionBox | null>(null);
const [frameDropTargetId, setFrameDropTargetId] = useState<string | null>(null);
const [isNodeDragging, setIsNodeDragging] = useState(false);
const [dragPreview, setDragPreview] = useState<{ x: number; y: number; nodeIds: Set<string> } | null>(null);
const [alignmentGuides, setAlignmentGuides] = useState<{ vertical?: number; horizontal?: number }>({});
const cancelSelectionBox = useCallback(() => {
selectionBoxRef.current = null;
selectionCandidatesRef.current = [];
pendingSelectionPointRef.current = null;
selectionActivatedRef.current = false;
if (selectionFrameRef.current) cancelAnimationFrame(selectionFrameRef.current);
selectionFrameRef.current = null;
setSelectionBox(null);
}, []);
const deselectCanvas = useCallback(() => {
cancelPendingConnectionCreate();
cancelSelectionBox();
const emptySelection = new Set<string>();
selectedNodeIdsRef.current = emptySelection;
setSelectedNodeIds(emptySelection);
setSelectedConnectionId(null);
onDeselect();
}, [cancelPendingConnectionCreate, cancelSelectionBox, onDeselect, selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
const handleCanvasMouseDown = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
cancelPendingConnectionCreate();
onCanvasSelectionStart();
if (event.button !== 0) return;
const world = screenToCanvas(event.clientX, event.clientY);
const subtractive = event.altKey;
const additive = !subtractive && (event.shiftKey || event.ctrlKey || event.metaKey);
const nextSelectionBox: SelectionBox = {
startWorldX: world.x,
startWorldY: world.y,
currentWorldX: world.x,
currentWorldY: world.y,
additive,
subtractive,
initialSelectedNodeIds: additive || subtractive ? Array.from(selectedNodeIdsRef.current) : [],
};
selectionBoxRef.current = nextSelectionBox;
selectionActivatedRef.current = false;
selectionCandidatesRef.current = nodesRef.current
.filter((node) => !isHiddenBatchChild(node, nodesRef.current) && !isNodeHiddenByCollapsedFrame(node, nodesRef.current))
.map((node) => ({ id: node.id, left: node.position.x, top: node.position.y, right: node.position.x + node.width, bottom: node.position.y + node.height }));
setSelectedConnectionId(null);
}, [cancelPendingConnectionCreate, nodesRef, onCanvasSelectionStart, screenToCanvas, selectedNodeIdsRef, setSelectedConnectionId]);
const handleNodeMouseDown = useCallback((event: ReactMouseEvent, nodeId: string) => {
event.stopPropagation();
if (event.button !== 0) return;
if (onBatchConnectionTarget?.(event, nodeId)) return;
setSelectedConnectionId(null);
const currentNodes = nodesRef.current;
const nextSelected = new Set(selectedNodeIdsRef.current);
const isSubtractClick = event.altKey;
const isMultiSelectClick = !isSubtractClick && (event.shiftKey || event.metaKey || event.ctrlKey);
const isSelectionModifier = isSubtractClick || isMultiSelectClick;
onNodeInteractionStart(isSelectionModifier);
if (isSubtractClick) nextSelected.delete(nodeId);
else if (isMultiSelectClick) {
if (nextSelected.has(nodeId)) nextSelected.delete(nodeId);
else nextSelected.add(nodeId);
} else if (!nextSelected.has(nodeId)) {
nextSelected.clear();
nextSelected.add(nodeId);
}
selectedNodeIdsRef.current = nextSelected;
setSelectedNodeIds(nextSelected);
if (isSelectionModifier && !nextSelected.has(nodeId)) {
dragRef.current = { ...EMPTY_DRAG_STATE, openPanelOnClick: false };
return;
}
const clickedNode = currentNodes.find((node) => node.id === nodeId);
if (clickedNode?.metadata?.locked) {
dragRef.current = { ...EMPTY_DRAG_STATE };
onNodeClick(clickedNode);
return;
}
const draggedNodeIds = currentNodes.filter((node) => nextSelected.has(node.id) && !node.metadata?.locked && !(node.parentId && nextSelected.has(node.parentId))).map((node) => node.id);
const dragIds = new Set(nextSelected);
currentNodes.forEach((node) => {
if (nextSelected.has(node.id)) node.metadata?.batchChildIds?.forEach((childId) => dragIds.add(childId));
if (nextSelected.has(node.id) && isFrameNode(node)) getFrameChildIds(node.id, currentNodes).forEach((childId) => dragIds.add(childId));
});
currentNodes.forEach((node) => {
if (dragIds.has(node.id)) node.metadata?.batchChildIds?.forEach((childId) => dragIds.add(childId));
});
const initialSelectedNodes = currentNodes.filter((node) => dragIds.has(node.id) && !node.metadata?.locked).map((node) => ({ id: node.id, x: node.position.x, y: node.position.y }));
if (!initialSelectedNodes.length) return;
dragRef.current = { isDraggingNode: true, hasMoved: false, openPanelOnClick: !isMultiSelectClick, startX: event.clientX, startY: event.clientY, draggedNodeIds, initialSelectedNodes };
historyPausedRef.current = true;
nodeDraggingRef.current = true;
pendingNodeDragRef.current = { x: 0, y: 0 };
alignmentContextRef.current = createNodeAlignmentContext(currentNodes, initialSelectedNodes);
lastFrameDropCheckRef.current = 0;
setIsNodeDragging(true);
setAlignmentGuides({});
setDragPreview({ x: 0, y: 0, nodeIds: new Set(initialSelectedNodes.map((item) => item.id)) });
}, [historyPausedRef, nodesRef, onBatchConnectionTarget, onNodeClick, onNodeInteractionStart, selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
const finishNodeDrag = useCallback((clientX?: number, clientY?: number) => {
if (dragFrameRef.current) {
cancelAnimationFrame(dragFrameRef.current);
dragFrameRef.current = null;
}
if (!dragRef.current.isDraggingNode) return;
const shouldOpenPanelOnClick = dragRef.current.openPanelOnClick;
const wasClick = shouldOpenPanelOnClick && !dragRef.current.hasMoved && dragRef.current.draggedNodeIds.length === 1;
const clickedNodeId = dragRef.current.draggedNodeIds[0];
const currentViewport = viewportRef.current;
const rawOffset = { x: clientX == null ? 0 : (clientX - dragRef.current.startX) / currentViewport.k, y: clientY == null ? 0 : (clientY - dragRef.current.startY) / currentViewport.k };
const initialPositions = dragRef.current.initialSelectedNodes;
const initialById = new Map(initialPositions.map((item) => [item.id, item]));
const { x: dx, y: dy } = calculateNodeAlignment(alignmentContextRef.current, rawOffset, 7 / currentViewport.k).offset;
historyPausedRef.current = false;
nodeDraggingRef.current = false;
setIsNodeDragging(false);
setDragPreview(null);
setAlignmentGuides({});
if (dragRef.current.hasMoved) {
const draggedNodeIds = new Set(dragRef.current.draggedNodeIds);
setNodes((currentNodes) => {
const positioned = clientX == null || clientY == null ? currentNodes : currentNodes.map((node) => {
const initial = initialById.get(node.id);
return initial ? { ...node, position: { x: initial.x + dx, y: initial.y + dy } } : node;
});
return applyFrameDrop(positioned, draggedNodeIds, findFrameDropTarget(positioned, draggedNodeIds));
});
}
setFrameDropTargetId(null);
alignmentContextRef.current = null;
dragRef.current = { ...EMPTY_DRAG_STATE };
if (wasClick && clickedNodeId) {
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
if (clickedNode) onNodeClick(clickedNode);
}
}, [historyPausedRef, nodesRef, onNodeClick, setNodes, viewportRef]);
const handleMouseMove = useCallback((event: MouseEvent) => {
if (!dragRef.current.isDraggingNode) return;
const currentViewport = viewportRef.current;
pendingNodeDragRef.current = { x: (event.clientX - dragRef.current.startX) / currentViewport.k, y: (event.clientY - dragRef.current.startY) / currentViewport.k };
if (Math.abs(event.clientX - dragRef.current.startX) > 3 || Math.abs(event.clientY - dragRef.current.startY) > 3) dragRef.current.hasMoved = true;
if (dragFrameRef.current) return;
dragFrameRef.current = requestAnimationFrame(() => {
const aligned = calculateNodeAlignment(alignmentContextRef.current, pendingNodeDragRef.current, 7 / viewportRef.current.k);
const latest = aligned.offset;
pendingAlignmentGuidesRef.current = aligned.guides;
const initialById = new Map(dragRef.current.initialSelectedNodes.map((item) => [item.id, item]));
const now = performance.now();
if (now - lastFrameDropCheckRef.current >= 100) {
lastFrameDropCheckRef.current = now;
const draggedNodeIds = new Set(dragRef.current.draggedNodeIds);
const positioned = nodesRef.current.map((node) => {
const initial = initialById.get(node.id);
return initial ? { ...node, position: { x: initial.x + latest.x, y: initial.y + latest.y } } : node;
});
setFrameDropTargetId(findFrameDropTarget(positioned, draggedNodeIds));
}
setDragPreview((current) => current ? { ...current, x: latest.x, y: latest.y } : current);
const nextGuides = dragRef.current.hasMoved ? pendingAlignmentGuidesRef.current : {};
setAlignmentGuides((current) => current.vertical === nextGuides.vertical && current.horizontal === nextGuides.horizontal ? current : nextGuides);
dragFrameRef.current = null;
});
}, [nodesRef, viewportRef]);
const handlePointerMove = useCallback((event: PointerEvent) => {
const currentSelection = selectionBoxRef.current;
if (!currentSelection) return;
if (event.buttons === 0) {
cancelSelectionBox();
return;
}
pendingSelectionPointRef.current = screenToCanvas(event.clientX, event.clientY);
if (selectionFrameRef.current) return;
selectionFrameRef.current = requestAnimationFrame(() => {
selectionFrameRef.current = null;
let selection = selectionBoxRef.current;
const world = pendingSelectionPointRef.current;
if (!selection || !world) return;
if (!selectionActivatedRef.current) {
const threshold = 4 / viewportRef.current.k;
if (Math.hypot(world.x - selection.startWorldX, world.y - selection.startWorldY) < threshold) return;
selectionActivatedRef.current = true;
selection = { ...selection, currentWorldX: world.x, currentWorldY: world.y };
selectionBoxRef.current = selection;
setSelectionBox(selection);
if (!selection.additive) {
const emptySelection = new Set<string>();
selectedNodeIdsRef.current = emptySelection;
setSelectedNodeIds(emptySelection);
}
}
const rectX = Math.min(selection.startWorldX, world.x);
const rectY = Math.min(selection.startWorldY, world.y);
const rectW = Math.abs(world.x - selection.startWorldX);
const rectH = Math.abs(world.y - selection.startWorldY);
selection = { ...selection, currentWorldX: world.x, currentWorldY: world.y };
selectionBoxRef.current = selection;
applyCanvasSelectionPreview(containerRef.current, selection);
const nextSelected = new Set<string>(selection.additive || selection.subtractive ? selection.initialSelectedNodeIds : []);
selectionCandidatesRef.current.forEach((node) => {
if (rectX >= node.right || rectX + rectW <= node.left || rectY >= node.bottom || rectY + rectH <= node.top) return;
if (selection.subtractive) nextSelected.delete(node.id);
else nextSelected.add(node.id);
});
if (sameStringSet(nextSelected, selectedNodeIdsRef.current)) return;
selectedNodeIdsRef.current = nextSelected;
setSelectedNodeIds(nextSelected);
});
}, [cancelSelectionBox, containerRef, screenToCanvas, selectedNodeIdsRef, setSelectedNodeIds, viewportRef]);
const finishSelection = useCallback(() => {
const hadPendingSelection = Boolean(selectionBoxRef.current);
const wasSelection = selectionActivatedRef.current;
cancelSelectionBox();
if (hadPendingSelection && !wasSelection) deselectCanvas();
if (hadPendingSelection) onSelectionBoxEnd?.();
}, [cancelSelectionBox, deselectCanvas, onSelectionBoxEnd]);
useEffect(() => {
const handleMouseUp = (event: MouseEvent) => {
finishNodeDrag(event.clientX, event.clientY);
finishSelection();
};
const handlePointerUp = (event: PointerEvent) => finishNodeDrag(event.clientX, event.clientY);
const cancel = () => finishNodeDrag();
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
window.addEventListener("pointermove", handlePointerMove);
window.addEventListener("pointerup", handlePointerUp);
window.addEventListener("pointercancel", cancel);
window.addEventListener("blur", cancel);
return () => {
if (dragFrameRef.current) cancelAnimationFrame(dragFrameRef.current);
if (selectionFrameRef.current) cancelAnimationFrame(selectionFrameRef.current);
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", cancel);
window.removeEventListener("blur", cancel);
};
}, [finishNodeDrag, finishSelection, handleMouseMove, handlePointerMove]);
return {
alignmentGuides,
cancelSelectionBox,
deselectCanvas,
dragPreview,
frameDropTargetId,
handleCanvasMouseDown,
handleNodeMouseDown,
isNodeDragging,
nodeDraggingRef,
selectionBoundsElementRef,
selectionBox,
};
}