perf(canvas): 画布媒体 - 优化媒体加载、播放器与节点交互 (#357)

Merge origin/main at fb089b2 and retain canvas media performance, player layout, and interaction updates.

Co-authored-by: ddCat <2695092045@qq.com>
This commit is contained in:
wurendao007andddCat authored and GitHub committed 2026-08-31 09:00:37 +08:00
1 parent 21facff923
commit 8ebceecbb6
58 files changed
+2662 -207

No files matched your search

@@ -44,8 +44,10 @@ type ConnectionDropTarget = {
type BatchConnectionDropTarget = ConnectionDropTarget;
const CONNECTION_HANDLE_HIT_RADIUS = 40;
const CONNECTION_NODE_HIT_PADDING = 32;
// LibTV's 80px world-space quick-add zone renders at roughly 110px on the
// reference canvas. Use the same generous 112px screen-space diameter here,
// while retaining a circular boundary around the corresponding side anchor.
const CONNECTION_SNAP_RADIUS = 56;
const NODE_STATUS_IDLE = "idle" as const;
function selectRunningHubWorkflow(config: AiConfig) {
@@ -95,6 +97,8 @@ export function useCanvasConnectionController({
const batchConnectionPreviewRef = useRef<CanvasBatchConnectionPreview | null>(null);
const batchConnectionPointerIdRef = useRef<number | null>(null);
const batchConnectionPointerStartRef = useRef<Position | null>(null);
const pointerMoveFrameRef = useRef<number | null>(null);
const latestPointerMoveRef = useRef<PointerEvent | null>(null);
useLayoutEffect(() => {
connectingParamsRef.current = connectingParams;
@@ -196,6 +200,8 @@ export function useCanvasConnectionController({
const nodeType = type;
if (nodeType === CanvasNodeType.Drawing && !isDrawingEngineAvailable(defaultDrawingEngine, tldrawLicenseKey)) {
message.error("当前生产构建未配置 tldraw License Key,不能创建 tldraw 绘图");
closeConnectionCreateMenu();
setConnecting(null);
return;
}
const batchSourceNodeIds = pending.batchSourceNodeIds?.length ? Array.from(new Set(pending.batchSourceNodeIds)) : [];
@@ -219,6 +225,7 @@ export function useCanvasConnectionController({
if (selectedWorkflowProvider && !workflowProviderPluginEnabled(runtimeStatuses, selectedWorkflowProvider)) {
message.error(`${selectedWorkflowProvider === "runninghub" ? "RunningHub" : "ComfyUI"} 工作流插件未启用`);
closeConnectionCreateMenu();
setConnecting(null);
return;
}
const runningHubWorkflow = selectedWorkflowProvider === "runninghub" ? selectRunningHubWorkflow(config) : undefined;
@@ -265,6 +272,7 @@ export function useCanvasConnectionController({
if (batchSourceNodeIds.length && nodeType === CanvasNodeType.Drawing) {
message.error("批量连接暂不支持创建绘图,请先连接到普通节点");
closeConnectionCreateMenu();
setConnecting(null);
return;
}
const batchPlan = batchSourceNodeIds.length
@@ -299,11 +307,15 @@ export function useCanvasConnectionController({
const connection = normalizeConnection(pending.connection.nodeId, newNode.id, [...nodesRef.current, newNode], pending.connection.handleType);
if (!connection) {
message.warning("当前节点不能建立这条连线");
closeConnectionCreateMenu();
setConnecting(null);
return;
}
const policyError = canvasConnectionError(config, [...nodesRef.current, newNode], connectionsRef.current, connection);
if (policyError) {
message.warning(policyError);
closeConnectionCreateMenu();
setConnecting(null);
return;
}
if (nodeType === CanvasNodeType.Drawing) {
@@ -311,6 +323,8 @@ export function useCanvasConnectionController({
const sourceUrl = drawingSourceNode?.type === CanvasNodeType.Image ? drawingSourceNode.metadata?.content : "";
if (pending.connection.handleType !== "source" || !drawingSourceNode || !sourceUrl || !newNode.metadata?.drawingId) {
message.error("只有已有图片内容的输出连线可以创建绘图");
closeConnectionCreateMenu();
setConnecting(null);
return;
}
closeConnectionCreateMenu();
@@ -373,8 +387,7 @@ export function useCanvasConnectionController({
const getConnectionDropTarget = useCallback((clientX: number, clientY: number, current: ConnectionHandle): ConnectionDropTarget => {
const world = screenToCanvas(clientX, clientY);
const scale = Math.max(viewportRef.current.k, 0.05);
const padding = CONNECTION_NODE_HIT_PADDING / scale;
const handleRadius = CONNECTION_HANDLE_HIT_RADIUS / scale;
const handleRadius = CONNECTION_SNAP_RADIUS / scale;
let isNearNode = false;
let bestNodeId: string | null = null;
let bestHandleId: string | undefined;
@@ -388,23 +401,25 @@ 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 targetAnchorRatio = node.type === CanvasNodeType.Script ? undefined : Math.min(0.94, Math.max(0.06, (world.y - node.position.y) / Math.max(node.height, 1)));
// Ordinary nodes expose one centered input/output port. Only
// storyboard rows have a meaningful vertical target position.
const targetAnchorRatio = undefined;
const anchor = getConnectionTargetAnchor(node, current, targetHandleId, scrollTop);
const dx = world.x - anchor.x;
const dy = world.y - anchor.y;
const hitsHandle = dx * dx + dy * dy <= handleRadius * handleRadius;
const hitsInside = world.x >= node.position.x && world.x <= node.position.x + node.width && world.y >= node.position.y && world.y <= node.position.y + node.height;
const hitsExpanded = world.x >= node.position.x - padding && world.x <= node.position.x + node.width + padding && world.y >= node.position.y - padding && world.y <= node.position.y + node.height + padding;
if (!hitsHandle && !hitsInside && !hitsExpanded) return;
// Do not treat the node body or a rectangular padding band as a
// connection target. LibTV snaps only inside the circular
// quick-add area centred on the corresponding side handle.
const hitsSnapZone = dx * dx + dy * dy <= handleRadius * handleRadius;
if (!hitsSnapZone) return;
isNearNode = true;
const normalized = node.id === current.nodeId ? null : normalizeConnection(current.nodeId, node.id, nodesRef.current, current.handleType);
if (!normalized || canvasConnectionError(config, nodesRef.current, connectionsRef.current, normalized)) return;
const priority = hitsInside ? 0 : hitsHandle ? 1 : 2;
if (priority < bestPriority) {
if (1 < bestPriority) {
bestNodeId = node.id;
bestHandleId = targetHandleId;
bestAnchorRatio = targetAnchorRatio;
bestPriority = priority;
bestPriority = 1;
}
});
return { nodeId: bestNodeId, handleId: bestHandleId, anchorRatio: bestAnchorRatio, isNearNode };
@@ -418,8 +433,7 @@ export function useCanvasConnectionController({
const world = screenToCanvas(clientX, clientY);
const scale = Math.max(viewportRef.current.k, 0.05);
const padding = CONNECTION_NODE_HIT_PADDING / scale;
const handleRadius = CONNECTION_HANDLE_HIT_RADIUS / scale;
const handleRadius = CONNECTION_SNAP_RADIUS / scale;
let isNearNode = false;
let bestNodeId: string | null = null;
let bestHandleId: string | undefined;
@@ -434,22 +448,19 @@ 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 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 targetAnchorRatio = undefined;
const anchor = getConnectionTargetAnchor(node, current, targetHandleId, scrollTop);
const dx = world.x - anchor.x;
const dy = world.y - anchor.y;
const hitsHandle = dx * dx + dy * dy <= handleRadius * handleRadius;
const hitsInside = world.x >= node.position.x && world.x <= node.position.x + node.width && world.y >= node.position.y && world.y <= node.position.y + node.height;
const hitsExpanded = world.x >= node.position.x - padding && world.x <= node.position.x + node.width + padding && world.y >= node.position.y - padding && world.y <= node.position.y + node.height + padding;
if (!hitsHandle && !hitsInside && !hitsExpanded) return;
const hitsSnapZone = dx * dx + dy * dy <= handleRadius * handleRadius;
if (!hitsSnapZone) return;
isNearNode = true;
if (!hasBatchConnectionCandidate(sourceNodeIds, node.id, nodesRef.current)) return;
const priority = hitsInside ? 0 : hitsHandle ? 1 : 2;
if (priority < bestPriority) {
if (1 < bestPriority) {
bestNodeId = node.id;
bestHandleId = targetHandleId;
bestAnchorRatio = targetAnchorRatio;
bestPriority = priority;
bestPriority = 1;
}
});
return { nodeId: bestNodeId, handleId: bestHandleId, anchorRatio: bestAnchorRatio, isNearNode };
@@ -544,6 +555,11 @@ export function useCanvasConnectionController({
const handleConnectStart = useCallback((event: ReactPointerEvent, nodeId: string, handleType: "source" | "target", handleId?: string, anchorRatio?: number) => {
event.preventDefault();
event.stopPropagation();
// A new pin interaction always starts a fresh session. Without this
// reset, an old quick-create menu can coexist with the new draft line;
// pointerup then sees the stale pending menu and silently aborts.
if (pendingConnectionCreateRef.current) closeConnectionCreateMenu();
if (connectingParamsRef.current) setConnecting(null);
if (batchConnectionPreviewRef.current && handleType === "target") {
commitBatchConnection(batchConnectionPreviewRef.current.sourceNodeIds, nodeId, handleId, anchorRatio);
clearBatchConnection();
@@ -557,10 +573,19 @@ export function useCanvasConnectionController({
setConnectionTargetNodeId(null);
setConnectionTargetAnchorRatio(undefined);
setSelectedConnectionId(null);
}, [clearBatchConnection, commitBatchConnection, screenToCanvas, setConnecting, setSelectedConnectionId]);
}, [clearBatchConnection, closeConnectionCreateMenu, commitBatchConnection, screenToCanvas, setConnecting, setSelectedConnectionId]);
useEffect(() => {
const handlePointerMove = (event: PointerEvent) => {
const cancelPendingPointerMove = () => {
latestPointerMoveRef.current = null;
if (pointerMoveFrameRef.current !== null) window.cancelAnimationFrame(pointerMoveFrameRef.current);
pointerMoveFrameRef.current = null;
};
const flushPointerMove = () => {
pointerMoveFrameRef.current = null;
const event = latestPointerMoveRef.current;
latestPointerMoveRef.current = null;
if (!event) return;
const batch = batchConnectionPreviewRef.current;
if (batch && (batchConnectionPointerIdRef.current === null || batchConnectionPointerIdRef.current === event.pointerId)) {
const target = getBatchConnectionDropTarget(event.clientX, event.clientY, batch.sourceNodeIds);
@@ -575,7 +600,19 @@ export function useCanvasConnectionController({
setConnectionTargetAnchorRatio(dropTarget.anchorRatio);
setMouseWorld(screenToCanvas(event.clientX, event.clientY));
};
const handlePointerMove = (event: PointerEvent) => {
// Pointer events can arrive faster than the canvas can paint. Keep
// only the latest position and update the preview once per frame,
// which prevents redundant React/Leafer work and visible jitter.
latestPointerMoveRef.current = event;
if (pointerMoveFrameRef.current === null) pointerMoveFrameRef.current = window.requestAnimationFrame(flushPointerMove);
};
const handlePointerUp = (event: PointerEvent) => {
latestPointerMoveRef.current = null;
if (pointerMoveFrameRef.current !== null) {
window.cancelAnimationFrame(pointerMoveFrameRef.current);
pointerMoveFrameRef.current = null;
}
if (batchConnectionPointerIdRef.current === event.pointerId) {
const start = batchConnectionPointerStartRef.current;
const batch = batchConnectionPreviewRef.current;
@@ -611,6 +648,7 @@ export function useCanvasConnectionController({
finishConnection(event.clientX, event.clientY);
};
const handlePointerCancel = (event: PointerEvent) => {
cancelPendingPointerMove();
if (batchConnectionPointerIdRef.current === event.pointerId) {
clearBatchConnection();
return;
@@ -618,6 +656,7 @@ export function useCanvasConnectionController({
if (connectingPointerIdRef.current === event.pointerId) setConnecting(null);
};
const cancel = () => {
cancelPendingPointerMove();
if (connectingParamsRef.current) setConnecting(null);
if (batchConnectionPreviewRef.current) clearBatchConnection();
};
@@ -626,6 +665,7 @@ export function useCanvasConnectionController({
window.addEventListener("pointercancel", handlePointerCancel);
window.addEventListener("blur", cancel);
return () => {
cancelPendingPointerMove();
window.removeEventListener("pointermove", handlePointerMove);
window.removeEventListener("pointerup", handlePointerUp);
window.removeEventListener("pointercancel", handlePointerCancel);