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:
wurendao007
2026-08-31 09:00:37 +08:00
committed by GitHub
parent 21facff923
commit 8ebceecbb6
58 changed files with 2662 additions and 207 deletions

View File

@@ -83,7 +83,7 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
styles={{ body: { padding: 0, display: "flex", justifyContent: "center", alignItems: "center", maxHeight: "84vh", overflow: "hidden", background: "#090909" } }}
>
{previewNode?.metadata?.content && previewNode.type === CanvasNodeType.Video ? (
<VideoPlayer src={previewNode.metadata.content} mimeType={previewNode.metadata.mimeType} title={previewNode.title || "视频预览"} className="max-h-[84vh] max-w-full bg-black" />
<VideoPlayer src={previewNode.metadata.content} mimeType={previewNode.metadata.mimeType} title={previewNode.title || "视频预览"} hasAudio={typeof previewNode.metadata.hasAudio === "boolean" ? previewNode.metadata.hasAudio : undefined} className="max-h-[84vh] max-w-full bg-black" />
) : null}
</Modal>

View File

@@ -5,6 +5,8 @@ import { ConnectionPath } from "@/components/canvas/canvas-connections";
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
import { CanvasNode } from "@/components/canvas/canvas-node";
import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
import { resolveActiveCanvasMediaNodeId } from "@/lib/canvas/canvas-performance-mode";
import { sortCanvasNodesByStackOrder, type CanvasNodeStackOrder } from "@/lib/canvas/canvas-node-stack-order";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { isFrameNode } from "@/lib/canvas/canvas-frame";
import type { CanvasDisplayConnection, CanvasFolderStyle, CanvasFolderTheme, CanvasNodeData, ConnectionHandle, Position, SelectionBox } from "@/types/canvas";
@@ -25,6 +27,7 @@ type CanvasProjectWorldLayersProps = {
connectionTargetNodeId: string | null;
nodeById: Map<string, CanvasNodeData>;
visibleNodes: CanvasNodeData[];
nodeStackOrder: CanvasNodeStackOrder;
frameChildrenById: Map<string, CanvasNodeData[]>;
linkedFolderPreviewNodesById: Map<string, CanvasNodeData[]>;
dragPreview: DragPreview;
@@ -80,6 +83,11 @@ const EMPTY_CANVAS_NODES: CanvasNodeData[] = [];
export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
const { viewportScale } = props;
const activeMediaNodeId = resolveActiveCanvasMediaNodeId(props.selectedNodeIds, props.nodeById);
const orderedVisibleNodes = [
...props.visibleNodes.filter(isFrameNode),
...sortCanvasNodesByStackOrder(props.visibleNodes.filter((node) => !isFrameNode(node)), props.nodeStackOrder),
];
const framePreviewNodes = (node: CanvasNodeData) => {
const assetFolderId = node.metadata?.folder?.assetFolderId;
if (assetFolderId) return props.linkedFolderPreviewNodesById.get(assetFolderId) || EMPTY_CANVAS_NODES;
@@ -110,7 +118,7 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
))}
</svg>
{props.visibleNodes.map((node) =>
{orderedVisibleNodes.map((node) =>
isFrameNode(node) ? (
<CanvasFrameNode
key={node.id}
@@ -135,6 +143,8 @@ export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(p
dragOffset={props.dragPreview?.nodeIds.has(node.id) ? props.dragPreview : undefined}
scale={viewportScale}
isSelected={props.selectedNodeIds.has(node.id)}
mediaActive={activeMediaNodeId === node.id}
hydrateMediaPreview={activeMediaNodeId === null}
isRelated={props.relatedNodeIds.has(node.id)}
isFocusRelated={props.activeNodeId === node.id}
isConnectionTarget={props.connectionTargetNodeId === node.id || props.batchConnectionPreview?.targetNodeId === node.id}

View File

@@ -2,7 +2,7 @@ import { App, Button, Input, Modal, Tag } from "antd";
import { CircleAlert, ExternalLink, Import, LoaderCircle } from "lucide-react";
import { useMemo, useState } from "react";
import { buildLibTVImagePreviewUrl, formatLibTVBatchTime, parseLibTVProjectUUID } from "@/lib/canvas/libtv-import";
import { buildLibTVImagePreviewUrl, buildLibTVVideoPreviewUrl, buildLibTVVideoSourceUrl, formatLibTVBatchTime, parseLibTVProjectUUID } from "@/lib/canvas/libtv-import";
import { importLibTVCanvas, type LibTVImportResult } from "@/services/api/libtv";
import { CanvasNodeType, type CanvasConnection, type CanvasNodeData, type ViewportTransform } from "@/types/canvas";
@@ -30,8 +30,8 @@ function buildCanvasNodes(result: LibTVImportResult, viewport: ViewportTransform
width: node.width,
height: node.height,
metadata: {
content: node.content,
previewContent: node.type === "image" ? buildLibTVImagePreviewUrl(node.content) : undefined,
content: node.type === "video" ? buildLibTVVideoSourceUrl(node.content) : node.content,
previewContent: node.type === "image" ? buildLibTVImagePreviewUrl(node.content) : buildLibTVVideoPreviewUrl(node.content) || undefined,
prompt: node.prompt,
model: node.model,
status: node.status || "idle",

View File

@@ -93,6 +93,7 @@ import { CanvasProjectSelectionToolbar } from "./canvas-project-selection-toolba
import { CanvasProjectStatusDialogs } from "./canvas-project-status-dialogs";
import { CanvasProjectWorldLayers } from "./canvas-project-world-layers";
import { CanvasNodeActionContext } from "@/components/canvas/canvas-node-action-context";
import { bringCanvasNodeToFront, type CanvasNodeStackOrder } from "@/lib/canvas/canvas-node-stack-order";
import { PortraitClearanceModal } from "@/components/canvas/portrait-clearance/portrait-clearance-modal";
import { CanvasNodeGraphContext, type CanvasNodeGraphContextValue } from "@/components/canvas/canvas-node-graph-context";
import { CanvasRefreshShell } from "./canvas-refresh-shell";
@@ -239,6 +240,10 @@ function InfiniteCanvasPage() {
nodesRef.current = next;
setNodesState(next);
}, []);
const [nodeStackOrder, setNodeStackOrder] = useState<CanvasNodeStackOrder>([]);
const bringNodeToFront = useCallback((nodeId: string) => {
setNodeStackOrder((current) => bringCanvasNodeToFront(current, nodeId));
}, []);
const [connections, setConnections] = useState<CanvasConnection[]>([]);
const [chatSessions, setChatSessions] = useState<CanvasAssistantSession[]>([]);
const [activeChatId, setActiveChatId] = useState<string | null>(null);
@@ -323,8 +328,17 @@ function InfiniteCanvasPage() {
useEffect(() => {
didInitialCenterRef.current = false;
setNodeStackOrder([]);
}, [projectId]);
useEffect(() => {
const nodeIds = new Set(nodes.map((node) => node.id));
setNodeStackOrder((current) => {
const next = current.filter((nodeId) => nodeIds.has(nodeId));
return next.length === current.length ? current : next;
});
}, [nodes]);
const connectionsRef = useRef(connections);
const chatSessionsRef = useRef(chatSessions);
const activeChatIdRef = useRef(activeChatId);
@@ -1002,6 +1016,9 @@ function InfiniteCanvasPage() {
}, []);
const handleSelectedNodeClick = useCallback((node: CanvasNodeData) => {
// Selection is transient, but the LibTV-style paint order survives
// deselection so a clicked lower node stays above its neighbours.
if (node.type !== CanvasNodeType.Frame) bringNodeToFront(node.id);
if (node.type === CanvasNodeType.Drawing) {
setDialogNodeId(null);
setDrawingNodeId(node.id);
@@ -1020,6 +1037,23 @@ function InfiniteCanvasPage() {
return currentNode?.type === CanvasNodeType.Config ? current : node.id;
});
}
}, [bringNodeToFront, nodesRef]);
const handleNodeBringToFront = useCallback((nodeId: string) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
if (node && node.type !== CanvasNodeType.Frame) bringNodeToFront(nodeId);
}, [bringNodeToFront, nodesRef]);
const handleNodeDragEnd = useCallback((nodeId: string) => {
const node = nodesRef.current.find((item) => item.id === nodeId);
if (!node || node.type === CanvasNodeType.Script || node.type === CanvasNodeType.Drawing || node.type === PORTRAIT_CLEARANCE_NODE_TYPE) {
setDialogNodeId(null);
return;
}
// A drag selects a new node even though it is not a click. Keep the
// generation editor bound to the node most recently moved so a stale
// panel from the previous node cannot reappear after mouse-up.
setDialogNodeId(node.id);
}, [nodesRef]);
const handleCanvasDeselect = useCallback(() => {
@@ -1042,7 +1076,9 @@ function InfiniteCanvasPage() {
cancelPendingConnectionCreate,
onCanvasSelectionStart: handleCanvasSelectionStart,
onNodeInteractionStart: handleNodeInteractionStart,
onNodeBringToFront: handleNodeBringToFront,
onNodeClick: handleSelectedNodeClick,
onNodeDragEnd: handleNodeDragEnd,
onBatchConnectionTarget: handleBatchConnectionTargetClick,
onLinkedFolderDrop: archiveNodesToLinkedFolder,
onDeselect: handleCanvasDeselect,
@@ -1279,6 +1315,10 @@ function InfiniteCanvasPage() {
});
}, [mentionReferencesByNodeId, setNodes]);
const dialogNode = dialogNodeId ? nodeById.get(dialogNodeId) || null : null;
// dragPreview is published on the same pointer-down frame as isNodeDragging.
// Treat either signal as moving so floating editors disappear before the
// first preview transform is painted and never affect drag layout.
const isCanvasNodeMoving = isNodeDragging || Boolean(dragPreview?.nodeIds.size);
const subtitleNode = subtitleNodeId ? nodeById.get(subtitleNodeId) || null : null;
const timelineNode = timelineNodeId ? nodeById.get(timelineNodeId) || null : null;
const frameNode = frameDialogNodeId ? nodeById.get(frameDialogNodeId) || null : null;
@@ -2177,6 +2217,7 @@ function InfiniteCanvasPage() {
connectionTargetNodeId={connectionTargetNodeId}
nodeById={nodeById}
visibleNodes={visibleNodes}
nodeStackOrder={nodeStackOrder}
frameChildrenById={frameChildrenById}
linkedFolderPreviewNodesById={linkedFolderPreviewNodesById}
dragPreview={dragPreview}
@@ -2332,7 +2373,7 @@ function InfiniteCanvasPage() {
) : null}
</div>
{angleNode?.metadata?.content ? (
{angleNode?.metadata?.content && !isCanvasNodeMoving ? (
<CanvasNodePanelOverlay
node={angleNode}
viewport={viewport}
@@ -2352,7 +2393,7 @@ function InfiniteCanvasPage() {
</CanvasNodePanelOverlay>
) : null}
{emotionNode?.metadata?.content ? (
{emotionNode?.metadata?.content && !isCanvasNodeMoving ? (
<CanvasEmotionWorkspace
node={emotionNode}
viewport={viewport}
@@ -2366,7 +2407,7 @@ function InfiniteCanvasPage() {
/>
) : null}
{dialogNode && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox ? (
{dialogNode && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox && !isCanvasNodeMoving ? (
<CanvasNodePanelOverlay
node={dialogNode}
viewport={viewport}
@@ -2391,7 +2432,7 @@ function InfiniteCanvasPage() {
/>
) : null}
{selectedNodeBounds && !selectionBox && !isNodeDragging ? (
{selectedNodeBounds && !selectionBox && !isCanvasNodeMoving ? (
<CanvasProjectSelectionToolbar
anchorRef={selectionBoundsElementRef}
containerRef={containerRef}
@@ -2422,7 +2463,7 @@ function InfiniteCanvasPage() {
) : null}
<CanvasNodeToolbar
node={isNodeDragging || nodeImageSettingsOpen || emotionNodeId ? null : toolbarNode}
node={isCanvasNodeMoving || nodeImageSettingsOpen || emotionNodeId ? null : toolbarNode}
workspaceMode={workspaceMode}
viewport={viewport}
containerRef={containerRef}

View File

@@ -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);

View File

@@ -252,7 +252,7 @@ export function useCanvasProjectLifecycle({
};
}
const hydratedMediaMetadataKeys = ["content", "storageKey", "naturalWidth", "naturalHeight", "bytes", "mimeType", "durationMs"] as const satisfies readonly (keyof CanvasNodeMetadata)[];
const hydratedMediaMetadataKeys = ["content", "storageKey", "naturalWidth", "naturalHeight", "bytes", "mimeType", "durationMs", "hasAudio", "videoPreview"] as const satisfies readonly (keyof CanvasNodeMetadata)[];
function mergeHydratedNodeMedia(currentNodes: CanvasNodeData[], initialNodes: CanvasNodeData[], hydratedNodes: CanvasNodeData[]) {
const initialById = new Map(initialNodes.map((node) => [node.id, node]));

View File

@@ -150,7 +150,9 @@ export function useCanvasRenderModel({
const emotionNode = emotionNodeId ? nodeById.get(emotionNodeId) || null : null;
const previewNode = previewNodeId ? nodeById.get(previewNodeId) || null : null;
const contextMenuNode = contextMenu?.type === "node" ? nodeById.get(contextMenu.nodeId) || null : null;
const activeNodeId = selectedNodeIds.size > 1 ? null : hoveredNodeId || (selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null);
// Hover only drives transient affordances (toolbar/handles). Selection is
// the explicit focus action that raises a node and highlights its graph.
const activeNodeId = selectedNodeIds.size === 1 ? Array.from(selectedNodeIds)[0] : null;
const selectedNodeBounds = useMemo(() => {
if (selectedNodeIds.size < 2) return null;

View File

@@ -18,7 +18,9 @@ type UseCanvasSelectionControllerOptions = {
cancelPendingConnectionCreate: () => void;
onCanvasSelectionStart: () => void;
onNodeInteractionStart: (selectionModifier: boolean) => void;
onNodeBringToFront?: (nodeId: string) => void;
onNodeClick: (node: CanvasNodeData) => void;
onNodeDragEnd?: (nodeId: string) => void;
onBatchConnectionTarget?: (event: ReactMouseEvent | ReactPointerEvent, nodeId: string) => boolean;
onLinkedFolderDrop?: (folder: CanvasNodeData, nodes: CanvasNodeData[]) => void;
onDeselect: () => void;
@@ -58,7 +60,9 @@ export function useCanvasSelectionController({
cancelPendingConnectionCreate,
onCanvasSelectionStart,
onNodeInteractionStart,
onNodeBringToFront,
onNodeClick,
onNodeDragEnd,
onBatchConnectionTarget,
onLinkedFolderDrop,
onDeselect,
@@ -131,6 +135,9 @@ export function useCanvasSelectionController({
event.stopPropagation();
if (event.button !== 0) return;
if (onBatchConnectionTarget?.(event, nodeId)) return;
// Paint order is session-local UI state. Update it once at the start
// of a real node interaction so dragging also survives deselection.
onNodeBringToFront?.(nodeId);
setSelectedConnectionId(null);
const currentNodes = nodesRef.current;
const nextSelected = new Set(selectedNodeIdsRef.current);
@@ -183,7 +190,7 @@ export function useCanvasSelectionController({
setIsNodeDragging(true);
setAlignmentGuides({});
setDragPreview({ x: 0, y: 0, nodeIds: new Set(initialSelectedNodes.map((item) => item.id)) });
}, [historyPausedRef, nodesRef, onBatchConnectionTarget, onNodeClick, onNodeInteractionStart, selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
}, [historyPausedRef, nodesRef, onBatchConnectionTarget, onNodeBringToFront, onNodeClick, onNodeInteractionStart, selectedNodeIdsRef, setSelectedConnectionId, setSelectedNodeIds]);
const finishNodeDrag = useCallback((clientX?: number, clientY?: number) => {
if (dragFrameRef.current) {
@@ -217,6 +224,7 @@ export function useCanvasSelectionController({
// 素材库文件夹只建立归档关系,不把画布节点变成其本地子节点。
setNodes(linkedFolder ? positioned : applyFrameDrop(positioned, draggedNodeIds, targetId));
if (linkedFolder) onLinkedFolderDrop?.(linkedFolder, positioned.filter((node) => draggedNodeIds.has(node.id)));
if (clickedNodeId) onNodeDragEnd?.(clickedNodeId);
}
setFrameDropTargetId(null);
alignmentContextRef.current = null;
@@ -225,7 +233,7 @@ export function useCanvasSelectionController({
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
if (clickedNode) onNodeClick(clickedNode);
}
}, [historyPausedRef, nodesRef, onLinkedFolderDrop, onNodeClick, setNodes, viewportRef]);
}, [historyPausedRef, nodesRef, onLinkedFolderDrop, onNodeClick, onNodeDragEnd, setNodes, viewportRef]);
const handleNodeDragMove = useCallback((event: MouseEvent | PointerEvent) => {
if (!dragRef.current.isDraggingNode) return;

View File

@@ -709,7 +709,7 @@ export function useCanvasUpload({
const spec = NODE_DEFAULT_SIZE[CanvasNodeType.Video];
const size = fitNodeSize(payload.width || spec.width, payload.height || spec.height, VIDEO_NODE_MAX_SIZE.width, VIDEO_NODE_MAX_SIZE.height);
const id = `video-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
return { id, type: CanvasNodeType.Video, title: payload.title, position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { content: payload.url, storageKey: payload.storageKey, status: NODE_STATUS_SUCCESS, naturalWidth: payload.width, naturalHeight: payload.height, durationMs: payload.durationMs, bytes: payload.bytes, mimeType: payload.mimeType || "video/mp4", assetId: payload.assetId } } satisfies CanvasNodeData;
return { id, type: CanvasNodeType.Video, title: payload.title, position: { x: center.x - size.width / 2, y: center.y - size.height / 2 }, width: size.width, height: size.height, metadata: { content: payload.url, storageKey: payload.storageKey, status: NODE_STATUS_SUCCESS, naturalWidth: payload.width, naturalHeight: payload.height, durationMs: payload.durationMs, hasAudio: payload.hasAudio, bytes: payload.bytes, mimeType: payload.mimeType || "video/mp4", assetId: payload.assetId } } satisfies CanvasNodeData;
}
const storedImage = payload.url
? { url: payload.url, storageKey: undefined, width: payload.width || 1, height: payload.height || 1, bytes: payload.bytes || 0, mimeType: payload.mimeType || "image/png" }

View File

@@ -1,5 +1,6 @@
import type { UploadedFile } from "@/services/file-storage";
import type { UploadedImage } from "@/services/image-storage";
import { canvasVideoAssetPreviewUrl } from "@/lib/canvas/canvas-media-preview";
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
import type { Asset, AudioAsset, ImageAsset, NewAsset } from "@/stores/use-asset-store";
import type { ReferenceImage } from "@/types/image";
@@ -279,7 +280,7 @@ export function creationVideoAsset({ title, uploaded, metadata }: { title: strin
return {
kind: "video",
title: title.trim() || "创作视频",
coverUrl: uploaded.url,
coverUrl: canvasVideoAssetPreviewUrl(uploaded.url, uploaded.preview?.url),
tags: ["创作"],
status: "confirmed",
source: "创作页",