feat(canvas): 画布引擎 - 引入 LeaferJS 图形层并优化缩放清晰度与性能

This commit is contained in:
ddCat
2026-07-31 13:12:06 +08:00
parent a8b3727c38
commit 035d4bc185
14 changed files with 591 additions and 101 deletions

View File

@@ -1,27 +1,20 @@
import type { MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react";
import { ActiveConnectionPath, ConnectionPath } from "@/components/canvas/canvas-connections";
import { ConnectionPath } from "@/components/canvas/canvas-connections";
import { CanvasFrameNode } from "@/components/canvas/canvas-frame-node";
import { CanvasNode } from "@/components/canvas/canvas-node";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { isFrameNode } from "@/lib/canvas/canvas-frame";
import type { CanvasConnection, CanvasNodeData, ConnectionHandle, Position, SelectionBox } from "@/types/canvas";
import type { CanvasDisplayConnection, CanvasNodeData, ConnectionHandle, Position, SelectionBox } from "@/types/canvas";
type CanvasTheme = {
accent: { primary: string };
canvas: { selectionFill: string };
};
type DisplayConnection = { connection: CanvasConnection; from: CanvasNodeData; to: CanvasNodeData };
type DragPreview = { x: number; y: number; nodeIds: Set<string> } | null;
type NodeBounds = { left: number; top: number; width: number; height: number; count: number } | null;
type CanvasProjectWorldLayersProps = {
projectId: string;
theme: CanvasTheme;
viewportScale: number;
connectionLayerBounds: { left: number; top: number; width: number; height: number };
displayConnections: DisplayConnection[];
displayConnections: CanvasDisplayConnection[];
selectedConnectionId: string | null;
relatedConnectionIds: Set<string>;
scriptScrollTopById: Record<string, number>;
@@ -49,7 +42,6 @@ type CanvasProjectWorldLayersProps = {
selectedNodeBounds: NodeBounds;
isNodeDragging: boolean;
selectionBoundsElementRef: RefObject<HTMLDivElement | null>;
selectionBoxElementRef: RefObject<HTMLDivElement | null>;
renderCanvasNodeContent: (node: CanvasNodeData) => ReactNode;
onConnectionSelect: (connectionId: string) => void;
onConnectionContextMenu: (event: ReactMouseEvent<SVGPathElement>, connectionId: string) => void;
@@ -79,7 +71,7 @@ const EMPTY_RESOURCE_REFERENCES: CanvasResourceReference[] = [];
const EMPTY_CANVAS_NODES: CanvasNodeData[] = [];
export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
const { theme, viewportScale } = props;
const { viewportScale } = props;
return (
<>
<svg
@@ -96,11 +88,11 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
fromScrollTop={props.scriptScrollTopById[from.id] || 0}
toScrollTop={props.scriptScrollTopById[to.id] || 0}
active={props.selectedConnectionId === connection.id || props.relatedConnectionIds.has(connection.id)}
visualMode="hover-only"
onSelect={() => props.onConnectionSelect(connection.id)}
onContextMenu={(event) => props.onConnectionContextMenu(event, connection.id)}
/>
))}
{props.connectingParams ? <ActiveConnectionPath node={props.nodeById.get(props.connectingParams.nodeId)} handle={props.connectingParams} mouseWorld={props.mouseWorld} target={props.connectionTargetNodeId ? props.nodeById.get(props.connectionTargetNodeId) : undefined} nodeScrollTop={props.scriptScrollTopById[props.connectingParams.nodeId] || 0} /> : null}
</svg>
{props.visibleNodes.map((node) =>
@@ -167,29 +159,12 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
{props.selectedNodeBounds && !props.selectionBox && !props.isNodeDragging ? (
<div
ref={props.selectionBoundsElementRef}
className="pointer-events-none absolute z-[65] rounded-xl border"
className="pointer-events-none absolute z-[65] rounded-xl"
style={{
left: props.selectedNodeBounds.left - 12 / viewportScale,
top: props.selectedNodeBounds.top - 12 / viewportScale,
width: props.selectedNodeBounds.width + 24 / viewportScale,
height: props.selectedNodeBounds.height + 24 / viewportScale,
borderColor: theme.accent.primary,
borderWidth: 1 / viewportScale,
}}
/>
) : null}
{props.selectionBox ? (
<div
ref={props.selectionBoxElementRef}
className="pointer-events-none absolute z-[100] border"
style={{
transform: `translate3d(var(--selection-x, ${Math.min(props.selectionBox.startWorldX, props.selectionBox.currentWorldX)}px), var(--selection-y, ${Math.min(props.selectionBox.startWorldY, props.selectionBox.currentWorldY)}px), 0)`,
width: `var(--selection-width, ${Math.abs(props.selectionBox.currentWorldX - props.selectionBox.startWorldX)}px)`,
height: `var(--selection-height, ${Math.abs(props.selectionBox.currentWorldY - props.selectionBox.startWorldY)}px)`,
borderColor: theme.accent.primary,
background: theme.canvas.selectionFill,
willChange: "transform,width,height",
}}
/>
) : null}

View File

@@ -46,7 +46,8 @@ import { CanvasVersionCompareModal } from "@/components/canvas/canvas-version-co
import { CanvasLocalAgentPanel } from "@/components/canvas/canvas-local-agent-panel";
import { useCanvasAgentStore } from "@/stores/canvas/use-canvas-agent-store";
import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import { CanvasAlignmentGuides, CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays";
import { CanvasConnectionCreateMenu, CanvasNodePanelOverlay } from "@/components/canvas/canvas-workspace-overlays";
import { CanvasLeaferGraphicsLayer } from "@/components/canvas/canvas-leafer-graphics-layer";
import { CanvasLinkedProjectEmptyState, CanvasShortDramaEmptyState, CanvasShortDramaGuide, CanvasStoryInputNodeContent, CanvasStylePlaceholderNodeContent } from "@/components/canvas/canvas-short-drama-entry";
import {
createCanvasNode,
@@ -680,8 +681,8 @@ function InfiniteCanvasPage() {
nodeDraggingRef,
selectionBoundsElementRef,
selectionBox,
selectionBoxElementRef,
} = useCanvasSelectionController({
containerRef,
nodesRef,
viewportRef,
selectedNodeIdsRef,
@@ -1400,6 +1401,24 @@ function InfiniteCanvasPage() {
containerRef={containerRef}
viewport={viewport}
backgroundMode={backgroundMode}
graphicsLayer={(
<CanvasLeaferGraphicsLayer
containerRef={containerRef}
viewport={viewport}
theme={theme}
displayConnections={displayConnections}
selectedConnectionId={selectedConnectionId}
relatedConnectionIds={relatedHighlight.connectionIds}
scriptScrollTopById={scriptScrollTopById}
connectingParams={connectingParams}
mouseWorld={mouseWorld}
connectionTargetNodeId={connectionTargetNodeId}
nodeById={nodeById}
selectionBox={selectionBox}
selectedNodeBounds={selectedNodeBounds}
alignmentGuides={alignmentGuides}
/>
)}
onViewportChange={handleViewportChange}
onViewportPreviewChange={handleViewportPreviewChange}
onCanvasMouseDown={handleCanvasMouseDown}
@@ -1413,7 +1432,6 @@ function InfiniteCanvasPage() {
>
<CanvasProjectWorldLayers
projectId={projectId}
theme={theme}
viewportScale={viewport.k}
connectionLayerBounds={connectionLayerBounds}
displayConnections={displayConnections}
@@ -1444,7 +1462,6 @@ function InfiniteCanvasPage() {
selectedNodeBounds={selectedNodeBounds}
isNodeDragging={isNodeDragging}
selectionBoundsElementRef={selectionBoundsElementRef}
selectionBoxElementRef={selectionBoxElementRef}
renderCanvasNodeContent={renderCanvasNodeContent}
onConnectionSelect={(connectionId) => { setSelectedConnectionId(connectionId); setSelectedNodeIds(new Set()); setContextMenu(null); }}
onConnectionContextMenu={(event, connectionId) => { setSelectedConnectionId(connectionId); setSelectedNodeIds(new Set()); closeConnectionCreateMenu(); setContextMenu({ type: "connection", x: event.clientX, y: event.clientY, connectionId }); }}
@@ -1501,8 +1518,6 @@ function InfiniteCanvasPage() {
{selectedNodeBounds && !selectionBox && !isNodeDragging ? <CanvasProjectSelectionToolbar anchorRef={selectionBoundsElementRef} containerRef={containerRef} count={selectedNodeBounds.count} selectedVideoCount={selectedVideoNodes.length} mergingVideos={Boolean(mergeVideoProgress)} onAlign={alignSelectedNodes} onArrange={arrangeSelectedNodes} onCreateStoryboard={createStoryboardGroup} onCreateReferenceGroup={createReferenceGroup} onMergeVideos={() => void mergeSelectedVideos()} /> : null}
<CanvasAlignmentGuides guides={{ vertical: alignmentGuides.vertical ?? null, horizontal: alignmentGuides.horizontal ?? null }} viewport={viewport} containerRef={containerRef} color={theme.accent.primary} />
{uploadStatus ? <CanvasUploadStatusToast status={uploadStatus} theme={theme} /> : null}
{mergeVideoProgress ? <CanvasMergeStatusToast progress={mergeVideoProgress} theme={theme} /> : null}
{lastAgentChange ? <CanvasAgentChangeToast change={lastAgentChange} theme={theme} onView={viewLastAgentChange} onUndo={() => { undoAgentOps(); }} onClose={dismissLastAgentChange} /> : null}

View File

@@ -1,10 +1,12 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type SetStateAction } from "react";
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> };
@@ -41,6 +43,7 @@ const EMPTY_DRAG_STATE: DragState = {
};
export function useCanvasSelectionController({
containerRef,
nodesRef,
viewportRef,
selectedNodeIdsRef,
@@ -61,7 +64,6 @@ export function useCanvasSelectionController({
const alignmentContextRef = useRef<NodeAlignmentContext | null>(null);
const lastFrameDropCheckRef = useRef(0);
const selectionFrameRef = useRef<number | null>(null);
const selectionBoxElementRef = useRef<HTMLDivElement>(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);
@@ -274,13 +276,9 @@ export function useCanvasSelectionController({
const rectY = Math.min(selection.startWorldY, world.y);
const rectW = Math.abs(world.x - selection.startWorldX);
const rectH = Math.abs(world.y - selection.startWorldY);
const element = selectionBoxElementRef.current;
if (element) {
element.style.setProperty("--selection-x", `${rectX}px`);
element.style.setProperty("--selection-y", `${rectY}px`);
element.style.setProperty("--selection-width", `${rectW}px`);
element.style.setProperty("--selection-height", `${rectH}px`);
}
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;
@@ -291,7 +289,7 @@ export function useCanvasSelectionController({
selectedNodeIdsRef.current = nextSelected;
setSelectedNodeIds(nextSelected);
});
}, [cancelSelectionBox, screenToCanvas, selectedNodeIdsRef, setSelectedNodeIds, viewportRef]);
}, [cancelSelectionBox, containerRef, screenToCanvas, selectedNodeIdsRef, setSelectedNodeIds, viewportRef]);
const finishSelection = useCallback(() => {
const hadPendingSelection = Boolean(selectionBoxRef.current);
@@ -337,6 +335,5 @@ export function useCanvasSelectionController({
nodeDraggingRef,
selectionBoundsElementRef,
selectionBox,
selectionBoxElementRef,
};
}