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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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: "创作页",
|
||||
|
||||
Reference in New Issue
Block a user