feat(canvas): 画布引擎 - 引入 LeaferJS 图形层并优化缩放清晰度与性能
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user