feat(canvas): 画布引擎 - 引入 LeaferJS 图形层并优化缩放清晰度与性能
This commit is contained in:
@@ -13,6 +13,7 @@ export const ConnectionPath = React.memo(function ConnectionPath({
|
||||
fromScrollTop = 0,
|
||||
toScrollTop = 0,
|
||||
active,
|
||||
visualMode = "full",
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
}: {
|
||||
@@ -22,19 +23,15 @@ export const ConnectionPath = React.memo(function ConnectionPath({
|
||||
fromScrollTop?: number;
|
||||
toScrollTop?: number;
|
||||
active: boolean;
|
||||
visualMode?: "full" | "hover-only";
|
||||
onSelect: () => void;
|
||||
onContextMenu?: (event: ReactMouseEvent<SVGPathElement>) => void;
|
||||
}) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const startX = from.position.x + from.width;
|
||||
const startY = connectionHandleY(from, connection.fromHandleId, fromScrollTop);
|
||||
const endX = to.position.x;
|
||||
const endY = connectionHandleY(to, connection.toHandleId, toScrollTop);
|
||||
const dx = Math.abs(endX - startX);
|
||||
const curvature = Math.max(dx * 0.5, 50);
|
||||
const pathD = `M ${startX} ${startY} C ${startX + curvature} ${startY}, ${endX - curvature} ${endY}, ${endX} ${endY}`;
|
||||
const { pathD, startX, startY, endX, endY } = canvasConnectionPath(connection, from, to, fromScrollTop, toScrollTop);
|
||||
const emphasized = active || hovered;
|
||||
const showVisual = visualMode === "full" || hovered;
|
||||
const gradientId = `canvas-flow-${connection.id.replace(/[^a-zA-Z0-9_-]/g, "")}`;
|
||||
|
||||
return (
|
||||
@@ -66,7 +63,7 @@ export const ConnectionPath = React.memo(function ConnectionPath({
|
||||
onContextMenu?.(event);
|
||||
}}
|
||||
/>
|
||||
<path
|
||||
{showVisual ? <path
|
||||
d={pathD}
|
||||
stroke={emphasized ? theme.accent.primary : theme.node.muted}
|
||||
strokeWidth={emphasized ? 1.6 : 1}
|
||||
@@ -75,8 +72,8 @@ export const ConnectionPath = React.memo(function ConnectionPath({
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
style={{ pointerEvents: "none" }}
|
||||
/>
|
||||
{emphasized ? <path
|
||||
/> : null}
|
||||
{showVisual && emphasized ? <path
|
||||
className="canvas-connection-flow"
|
||||
d={pathD}
|
||||
stroke={`url(#${gradientId})`}
|
||||
@@ -90,12 +87,20 @@ export const ConnectionPath = React.memo(function ConnectionPath({
|
||||
/> : null}
|
||||
</g>
|
||||
);
|
||||
}, (previous, next) => previous.connection === next.connection && previous.from === next.from && previous.to === next.to && previous.active === next.active && previous.fromScrollTop === next.fromScrollTop && previous.toScrollTop === next.toScrollTop);
|
||||
}, (previous, next) => previous.connection === next.connection && previous.from === next.from && previous.to === next.to && previous.active === next.active && previous.visualMode === next.visualMode && previous.fromScrollTop === next.fromScrollTop && previous.toScrollTop === next.toScrollTop);
|
||||
|
||||
export function ActiveConnectionPath({ node, handle, mouseWorld, target, nodeScrollTop = 0 }: { node?: CanvasNodeData; handle: ConnectionHandle; mouseWorld: Position; target?: CanvasNodeData; nodeScrollTop?: number }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
if (!node) return null;
|
||||
export function canvasConnectionPath(connection: CanvasConnection, from: CanvasNodeData, to: CanvasNodeData, fromScrollTop = 0, toScrollTop = 0) {
|
||||
const startX = from.position.x + from.width;
|
||||
const startY = connectionHandleY(from, connection.fromHandleId, fromScrollTop);
|
||||
const endX = to.position.x;
|
||||
const endY = connectionHandleY(to, connection.toHandleId, toScrollTop);
|
||||
const dx = Math.abs(endX - startX);
|
||||
const curvature = Math.max(dx * 0.5, 50);
|
||||
return { pathD: `M ${startX} ${startY} C ${startX + curvature} ${startY}, ${endX - curvature} ${endY}, ${endX} ${endY}`, startX, startY, endX, endY };
|
||||
}
|
||||
|
||||
export function activeConnectionPath(node: CanvasNodeData | undefined, handle: ConnectionHandle, mouseWorld: Position, target?: CanvasNodeData, nodeScrollTop = 0) {
|
||||
if (!node) return "";
|
||||
const startX = handle.handleType === "source" ? node.position.x + node.width : mouseWorld.x;
|
||||
const startY = handle.handleType === "source" ? connectionHandleY(node, handle.handleId, nodeScrollTop) : mouseWorld.y;
|
||||
const endX = handle.handleType === "source" ? mouseWorld.x : node.position.x;
|
||||
@@ -105,12 +110,10 @@ export function ActiveConnectionPath({ node, handle, mouseWorld, target, nodeScr
|
||||
const snappedEndX = handle.handleType === "source" && target ? target.position.x : endX;
|
||||
const snappedEndY = handle.handleType === "source" && target ? target.position.y + target.height / 2 : endY;
|
||||
const distance = Math.abs(snappedEndX - snappedStartX);
|
||||
const pathD = `M ${snappedStartX} ${snappedStartY} C ${snappedStartX + distance * 0.5} ${snappedStartY}, ${snappedEndX - distance * 0.5} ${snappedEndY}, ${snappedEndX} ${snappedEndY}`;
|
||||
|
||||
return <path className="canvas-connection-draft" d={pathD} stroke={theme.accent.primary} strokeWidth="1.4" strokeOpacity="0.72" vectorEffect="non-scaling-stroke" fill="none" strokeDasharray="8,8" strokeLinecap="round" />;
|
||||
return `M ${snappedStartX} ${snappedStartY} C ${snappedStartX + distance * 0.5} ${snappedStartY}, ${snappedEndX - distance * 0.5} ${snappedEndY}, ${snappedEndX} ${snappedEndY}`;
|
||||
}
|
||||
|
||||
function connectionHandleY(node: CanvasNodeData, handleId?: string, scrollTop = 0) {
|
||||
export function connectionHandleY(node: CanvasNodeData, handleId?: string, scrollTop = 0) {
|
||||
if (handleId === "storyboard:context") return node.position.y + node.height - (node.metadata?.storyboardComposerHeight || 104) / 2;
|
||||
if (!handleId?.startsWith("row:")) return node.position.y + node.height / 2;
|
||||
const rowId = handleId.slice(4);
|
||||
|
||||
325
web/src/components/canvas/canvas-leafer-graphics-layer.tsx
Normal file
325
web/src/components/canvas/canvas-leafer-graphics-layer.tsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import { useLayoutEffect, useRef, type RefObject } from "react";
|
||||
import { Group, Leafer, Path, Rect } from "leafer-ui";
|
||||
|
||||
import { activeConnectionPath, canvasConnectionPath } from "@/components/canvas/canvas-connections";
|
||||
import { subscribeCanvasSelectionPreview, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
|
||||
import { calculateCanvasPreviewTransform, sameCanvasViewport, shouldRebaseCanvasRaster } from "@/lib/canvas/canvas-leafer-viewport";
|
||||
import type { CanvasTheme } from "@/lib/canvas-theme";
|
||||
import type { CanvasDisplayConnection, CanvasNodeData, ConnectionHandle, Position, SelectionBox, ViewportTransform } from "@/types/canvas";
|
||||
|
||||
type NodeBounds = { left: number; top: number; width: number; height: number; count: number } | null;
|
||||
|
||||
type CanvasLeaferGraphicsLayerProps = {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
viewport: ViewportTransform;
|
||||
theme: CanvasTheme;
|
||||
displayConnections: CanvasDisplayConnection[];
|
||||
selectedConnectionId: string | null;
|
||||
relatedConnectionIds: Set<string>;
|
||||
scriptScrollTopById: Record<string, number>;
|
||||
connectingParams: ConnectionHandle | null;
|
||||
mouseWorld: Position;
|
||||
connectionTargetNodeId: string | null;
|
||||
nodeById: Map<string, CanvasNodeData>;
|
||||
selectionBox: SelectionBox | null;
|
||||
selectedNodeBounds: NodeBounds;
|
||||
alignmentGuides: { vertical?: number; horizontal?: number };
|
||||
};
|
||||
|
||||
type LeaferScene = {
|
||||
leafer: Leafer;
|
||||
world: Group;
|
||||
host: HTMLDivElement;
|
||||
};
|
||||
|
||||
type UnderlayScene = LeaferScene & {
|
||||
connections: Group;
|
||||
};
|
||||
|
||||
type OverlayScene = LeaferScene & {
|
||||
selection: Rect;
|
||||
selectionBounds: Rect;
|
||||
guides: Path;
|
||||
draft: Path;
|
||||
};
|
||||
|
||||
export function CanvasLeaferGraphicsLayer(props: CanvasLeaferGraphicsLayerProps) {
|
||||
const underlayHostRef = useRef<HTMLDivElement>(null);
|
||||
const overlayHostRef = useRef<HTMLDivElement>(null);
|
||||
const underlayRef = useRef<UnderlayScene | null>(null);
|
||||
const overlayRef = useRef<OverlayScene | null>(null);
|
||||
const viewportRef = useRef(props.viewport);
|
||||
const rasterViewportRef = useRef(props.viewport);
|
||||
const propsRef = useRef(props);
|
||||
propsRef.current = props;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const underlayHost = underlayHostRef.current;
|
||||
const overlayHost = overlayHostRef.current;
|
||||
// 子组件 layout effect 可能早于父层 ref 对外可见,host 的直接父元素才是此刻最可靠的画布容器。
|
||||
const container = (props.containerRef.current || underlayHost?.parentElement) as HTMLDivElement | null;
|
||||
if (!underlayHost || !overlayHost || !container) return;
|
||||
|
||||
const underlay = createUnderlayScene(underlayHost);
|
||||
const overlay = createOverlayScene(overlayHost);
|
||||
underlayRef.current = underlay;
|
||||
overlayRef.current = overlay;
|
||||
|
||||
const resize = () => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const size = { width: Math.max(1, rect.width), height: Math.max(1, rect.height), pixelRatio: canvasPixelRatio() };
|
||||
underlay.leafer.resize(size);
|
||||
overlay.leafer.resize(size);
|
||||
syncViewport(rasterViewportRef.current, size.width, size.height, underlay, overlay, propsRef.current);
|
||||
if (isZoomPreview(container, viewportRef.current, rasterViewportRef.current)) {
|
||||
applyScenePreview(viewportRef.current, rasterViewportRef.current, underlay, overlay);
|
||||
}
|
||||
};
|
||||
const resizeObserver = new ResizeObserver(resize);
|
||||
resizeObserver.observe(container);
|
||||
window.addEventListener("resize", resize);
|
||||
const unsubscribe = subscribeCanvasViewportPreview(container, (next) => {
|
||||
viewportRef.current = next;
|
||||
const rect = container.getBoundingClientRect();
|
||||
if (isZoomPreview(container, next, rasterViewportRef.current)) {
|
||||
if (shouldRebaseCanvasRaster(next, rasterViewportRef.current)) {
|
||||
syncViewport(next, rect.width, rect.height, underlay, overlay, propsRef.current);
|
||||
rasterViewportRef.current = next;
|
||||
forceSceneRender(underlay, overlay);
|
||||
resetScenePreview(underlay, overlay);
|
||||
return;
|
||||
}
|
||||
applyScenePreview(next, rasterViewportRef.current, underlay, overlay);
|
||||
return;
|
||||
}
|
||||
resetScenePreview(underlay, overlay);
|
||||
if (sameCanvasViewport(next, rasterViewportRef.current)) return;
|
||||
syncViewport(next, rect.width, rect.height, underlay, overlay, propsRef.current);
|
||||
rasterViewportRef.current = next;
|
||||
});
|
||||
const unsubscribeSelection = subscribeCanvasSelectionPreview(container, (selection) => {
|
||||
syncSelection(overlay.selection, selection, propsRef.current.theme);
|
||||
});
|
||||
resize();
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
unsubscribeSelection();
|
||||
resizeObserver.disconnect();
|
||||
window.removeEventListener("resize", resize);
|
||||
underlay.leafer.destroy(true);
|
||||
overlay.leafer.destroy(true);
|
||||
underlayRef.current = null;
|
||||
overlayRef.current = null;
|
||||
};
|
||||
}, [props.containerRef]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const underlay = underlayRef.current;
|
||||
if (!underlay) return;
|
||||
rebuildConnections(underlay, props);
|
||||
}, [props.displayConnections, props.relatedConnectionIds, props.scriptScrollTopById, props.selectedConnectionId, props.theme]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const overlay = overlayRef.current;
|
||||
if (!overlay) return;
|
||||
syncOverlayContent(overlay, props, viewportRef.current.k);
|
||||
}, [props.connectingParams, props.connectionTargetNodeId, props.mouseWorld, props.nodeById, props.scriptScrollTopById, props.selectedNodeBounds, props.selectionBox, props.theme]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const underlay = underlayRef.current;
|
||||
const overlay = overlayRef.current;
|
||||
const container = props.containerRef.current;
|
||||
if (!underlay || !overlay || !container) return;
|
||||
viewportRef.current = props.viewport;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const hadPreview = hasScenePreview(underlay, overlay);
|
||||
if (hadPreview || !sameCanvasViewport(props.viewport, rasterViewportRef.current)) {
|
||||
syncViewport(props.viewport, rect.width, rect.height, underlay, overlay, props);
|
||||
}
|
||||
rasterViewportRef.current = props.viewport;
|
||||
// 新视口先同步到真实 DPR backing store,再撤销交互期的合成变换,避免出现跳帧。
|
||||
if (hadPreview) forceSceneRender(underlay, overlay);
|
||||
resetScenePreview(underlay, overlay);
|
||||
}, [props.containerRef, props.viewport]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const underlay = underlayRef.current;
|
||||
const overlay = overlayRef.current;
|
||||
const container = props.containerRef.current;
|
||||
if (!underlay || !overlay || !container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
syncViewport(rasterViewportRef.current, rect.width, rect.height, underlay, overlay, props);
|
||||
if (isZoomPreview(container, viewportRef.current, rasterViewportRef.current)) {
|
||||
applyScenePreview(viewportRef.current, rasterViewportRef.current, underlay, overlay);
|
||||
}
|
||||
}, [props.alignmentGuides, props.containerRef, props.theme]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={underlayHostRef} data-canvas-leafer-underlay className="pointer-events-none absolute inset-0 z-0 overflow-hidden" aria-hidden />
|
||||
<div ref={overlayHostRef} data-canvas-leafer-overlay className="pointer-events-none absolute inset-0 z-[55] overflow-hidden" aria-hidden />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function createUnderlayScene(host: HTMLDivElement): UnderlayScene {
|
||||
const leafer = new Leafer({ view: host, width: 1, height: 1, pixelRatio: canvasPixelRatio(), fill: "transparent", hittable: false, smooth: true });
|
||||
const world = new Group({ hittable: false });
|
||||
const connections = new Group({ hittable: false });
|
||||
world.add(connections);
|
||||
leafer.add(world);
|
||||
return { leafer, world, host, connections };
|
||||
}
|
||||
|
||||
function createOverlayScene(host: HTMLDivElement): OverlayScene {
|
||||
const leafer = new Leafer({ view: host, width: 1, height: 1, pixelRatio: canvasPixelRatio(), fill: "transparent", hittable: false, smooth: true });
|
||||
const world = new Group({ hittable: false });
|
||||
const selection = new Rect({ visible: false, hittable: false });
|
||||
const selectionBounds = new Rect({ visible: false, hittable: false, fill: "transparent" });
|
||||
const guides = new Path({ visible: false, hittable: false });
|
||||
const draft = new Path({ visible: false, hittable: false });
|
||||
world.add(selection);
|
||||
world.add(selectionBounds);
|
||||
world.add(guides);
|
||||
world.add(draft);
|
||||
leafer.add(world);
|
||||
return { leafer, world, host, selection, selectionBounds, guides, draft };
|
||||
}
|
||||
|
||||
function rebuildConnections(scene: UnderlayScene, props: CanvasLeaferGraphicsLayerProps) {
|
||||
scene.connections.removeAll(true);
|
||||
props.displayConnections.forEach(({ connection, from, to }) => {
|
||||
const emphasized = props.selectedConnectionId === connection.id || props.relatedConnectionIds.has(connection.id);
|
||||
const path = new Path({
|
||||
path: canvasConnectionPath(connection, from, to, props.scriptScrollTopById[from.id] || 0, props.scriptScrollTopById[to.id] || 0).pathD,
|
||||
stroke: emphasized ? props.theme.accent.primary : props.theme.node.muted,
|
||||
strokeWidth: emphasized ? 1.6 : 1,
|
||||
strokeScaleFixed: true,
|
||||
strokeCap: "round",
|
||||
opacity: emphasized ? 0.52 : 0.24,
|
||||
hittable: false,
|
||||
});
|
||||
scene.connections.add(path);
|
||||
});
|
||||
}
|
||||
|
||||
function syncOverlayContent(scene: OverlayScene, props: CanvasLeaferGraphicsLayerProps, viewportScale: number) {
|
||||
const selection = props.selectionBox;
|
||||
scene.selection.visible = Boolean(selection);
|
||||
if (selection) {
|
||||
syncSelection(scene.selection, selection, props.theme);
|
||||
}
|
||||
|
||||
const bounds = props.selectedNodeBounds;
|
||||
scene.selectionBounds.visible = Boolean(bounds && !selection);
|
||||
if (bounds && !selection) {
|
||||
syncSelectionBounds(scene.selectionBounds, bounds, viewportScale);
|
||||
scene.selectionBounds.stroke = props.theme.accent.primary;
|
||||
}
|
||||
|
||||
const connecting = props.connectingParams;
|
||||
scene.draft.visible = Boolean(connecting);
|
||||
if (connecting) {
|
||||
scene.draft.set({
|
||||
path: activeConnectionPath(
|
||||
props.nodeById.get(connecting.nodeId),
|
||||
connecting,
|
||||
props.mouseWorld,
|
||||
props.connectionTargetNodeId ? props.nodeById.get(props.connectionTargetNodeId) : undefined,
|
||||
props.scriptScrollTopById[connecting.nodeId] || 0,
|
||||
),
|
||||
stroke: props.theme.accent.primary,
|
||||
strokeCap: "round",
|
||||
opacity: 0.72,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function syncSelection(rect: Rect, selection: SelectionBox, theme: CanvasTheme) {
|
||||
rect.set({
|
||||
x: Math.min(selection.startWorldX, selection.currentWorldX),
|
||||
y: Math.min(selection.startWorldY, selection.currentWorldY),
|
||||
width: Math.abs(selection.currentWorldX - selection.startWorldX),
|
||||
height: Math.abs(selection.currentWorldY - selection.startWorldY),
|
||||
fill: theme.canvas.selectionFill,
|
||||
stroke: theme.accent.primary,
|
||||
});
|
||||
}
|
||||
|
||||
function syncViewport(viewport: ViewportTransform, width: number, height: number, underlay: UnderlayScene, overlay: OverlayScene, props: CanvasLeaferGraphicsLayerProps) {
|
||||
const scale = Math.max(viewport.k, 0.05);
|
||||
for (const scene of [underlay, overlay]) scene.world.set({ x: viewport.x, y: viewport.y, scaleX: scale, scaleY: scale });
|
||||
|
||||
overlay.selection.strokeWidth = 1 / scale;
|
||||
overlay.selection.cornerRadius = 2 / scale;
|
||||
if (props.selectedNodeBounds) syncSelectionBounds(overlay.selectionBounds, props.selectedNodeBounds, scale);
|
||||
overlay.selectionBounds.set({ strokeWidth: 1 / scale, cornerRadius: 12 / scale });
|
||||
overlay.draft.set({ strokeWidth: 1.4 / scale, dashPattern: [8 / scale, 8 / scale] });
|
||||
overlay.guides.set({
|
||||
visible: typeof props.alignmentGuides.vertical === "number" || typeof props.alignmentGuides.horizontal === "number",
|
||||
path: guidePath(viewport, width, height, props.alignmentGuides),
|
||||
stroke: props.theme.accent.primary,
|
||||
strokeWidth: 1 / scale,
|
||||
dashPattern: [5 / scale, 5 / scale],
|
||||
opacity: 0.72,
|
||||
});
|
||||
}
|
||||
|
||||
function isZoomPreview(container: HTMLDivElement, viewport: ViewportTransform, rasterViewport: ViewportTransform) {
|
||||
return container.dataset.canvasViewportInteracting === "true" && Math.abs(viewport.k - rasterViewport.k) > 0.0001;
|
||||
}
|
||||
|
||||
function applyScenePreview(viewport: ViewportTransform, rasterViewport: ViewportTransform, ...scenes: LeaferScene[]) {
|
||||
// 将已栅格画面的屏幕坐标映射到实时视口,缩放手势期间不触碰 Leafer 场景树。
|
||||
const { ratio, x, y } = calculateCanvasPreviewTransform(viewport, rasterViewport);
|
||||
for (const scene of scenes) {
|
||||
scene.host.style.transformOrigin = "0 0";
|
||||
scene.host.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${ratio})`;
|
||||
scene.host.style.willChange = "transform";
|
||||
scene.host.dataset.canvasLeaferPreview = "true";
|
||||
}
|
||||
}
|
||||
|
||||
function hasScenePreview(...scenes: LeaferScene[]) {
|
||||
return scenes.some((scene) => scene.host.dataset.canvasLeaferPreview === "true");
|
||||
}
|
||||
|
||||
function resetScenePreview(...scenes: LeaferScene[]) {
|
||||
for (const scene of scenes) {
|
||||
scene.host.style.transform = "";
|
||||
scene.host.style.transformOrigin = "";
|
||||
scene.host.style.willChange = "";
|
||||
delete scene.host.dataset.canvasLeaferPreview;
|
||||
}
|
||||
}
|
||||
|
||||
function forceSceneRender(...scenes: LeaferScene[]) {
|
||||
for (const scene of scenes) scene.leafer.forceRender(undefined, true);
|
||||
}
|
||||
|
||||
function syncSelectionBounds(rect: Rect, bounds: NonNullable<NodeBounds>, viewportScale: number) {
|
||||
const padding = 12 / Math.max(viewportScale, 0.05);
|
||||
rect.set({
|
||||
x: bounds.left - padding,
|
||||
y: bounds.top - padding,
|
||||
width: bounds.width + padding * 2,
|
||||
height: bounds.height + padding * 2,
|
||||
});
|
||||
}
|
||||
|
||||
function guidePath(viewport: ViewportTransform, width: number, height: number, guides: { vertical?: number; horizontal?: number }) {
|
||||
const scale = Math.max(viewport.k, 0.05);
|
||||
const left = -viewport.x / scale;
|
||||
const top = -viewport.y / scale;
|
||||
const right = left + width / scale;
|
||||
const bottom = top + height / scale;
|
||||
const commands: string[] = [];
|
||||
if (typeof guides.vertical === "number") commands.push(`M ${guides.vertical} ${top} L ${guides.vertical} ${bottom}`);
|
||||
if (typeof guides.horizontal === "number") commands.push(`M ${left} ${guides.horizontal} L ${right} ${guides.horizontal}`);
|
||||
return commands.join(" ");
|
||||
}
|
||||
|
||||
function canvasPixelRatio() {
|
||||
return Math.min(3, Math.max(1, window.devicePixelRatio || 1));
|
||||
}
|
||||
@@ -180,29 +180,6 @@ export function CanvasConnectionCreateMenu({ pending, viewport, viewportSize, co
|
||||
);
|
||||
}
|
||||
|
||||
export function CanvasAlignmentGuides({ guides, viewport, containerRef, color }: { guides: { vertical: number | null; horizontal: number | null }; viewport: ViewportTransform; containerRef: RefObject<HTMLDivElement | null>; color: string }) {
|
||||
const verticalRef = useRef<HTMLDivElement>(null);
|
||||
const horizontalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const update = (nextViewport: ViewportTransform) => {
|
||||
if (verticalRef.current && typeof guides.vertical === "number") verticalRef.current.style.left = `${nextViewport.x + guides.vertical * nextViewport.k}px`;
|
||||
if (horizontalRef.current && typeof guides.horizontal === "number") horizontalRef.current.style.top = `${nextViewport.y + guides.horizontal * nextViewport.k}px`;
|
||||
};
|
||||
update(viewport);
|
||||
return subscribeCanvasViewportPreview(container, update);
|
||||
}, [containerRef, guides.horizontal, guides.vertical, viewport]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{typeof guides.vertical === "number" ? <div ref={verticalRef} className="pointer-events-none absolute bottom-0 top-0 z-[55] border-l border-dashed" style={{ left: viewport.x + guides.vertical * viewport.k, borderColor: color }} /> : null}
|
||||
{typeof guides.horizontal === "number" ? <div ref={horizontalRef} className="pointer-events-none absolute left-0 right-0 z-[55] border-t border-dashed" style={{ top: viewport.y + guides.horizontal * viewport.k, borderColor: color }} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionCreateOption({ motionEnabled, icon, title, description, onClick }: { motionEnabled: boolean; icon: ReactNode; title: string; description?: string; onClick: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
import { canvasThemes, type CanvasBackgroundMode } from "@/lib/canvas-theme";
|
||||
import { applyCanvasLiveViewport, subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
|
||||
@@ -20,11 +20,12 @@ type InfiniteCanvasProps = {
|
||||
onFileDragLeave?: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||
onFileDragOver?: (event: React.DragEvent<HTMLDivElement>) => void;
|
||||
children: React.ReactNode;
|
||||
graphicsLayer?: React.ReactNode;
|
||||
};
|
||||
|
||||
const CANVAS_WHEEL_IGNORE_SELECTOR = "[data-canvas-no-zoom],[data-canvas-wheel-scroll],.ant-modal,.ant-popover,.ant-dropdown,.ant-select-dropdown,.ant-picker-dropdown";
|
||||
const WHEEL_ZOOM_DELTA = 100;
|
||||
const TRACKPAD_PINCH_ZOOM_DELTA = 36;
|
||||
const WHEEL_ZOOM_DELTA = 72;
|
||||
const TRACKPAD_PINCH_ZOOM_DELTA = 24;
|
||||
|
||||
type TouchPoint = { x: number; y: number };
|
||||
|
||||
@@ -37,7 +38,7 @@ type PinchState = {
|
||||
initialScale: number;
|
||||
};
|
||||
|
||||
export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, children }: InfiniteCanvasProps) {
|
||||
export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines", onViewportChange, onViewportPreviewChange, onCanvasMouseDown, onCanvasDoubleClick, onCanvasDeselect, onContextMenu, onDrop, onFileDragEnter, onFileDragLeave, onFileDragOver, graphicsLayer, children }: InfiniteCanvasProps) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const panState = useRef({
|
||||
isPanning: false,
|
||||
@@ -61,7 +62,7 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
|
||||
const [isSpacePressed, setIsSpacePressed] = useState(false);
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
useLayoutEffect(() => {
|
||||
if (interactingRef.current) return;
|
||||
viewportRef.current = viewport;
|
||||
scaleRef.current = viewport.k;
|
||||
@@ -373,6 +374,8 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
|
||||
"--canvas-live-x": `${viewport.x}px`,
|
||||
"--canvas-live-y": `${viewport.y}px`,
|
||||
"--canvas-live-scale": viewport.k,
|
||||
"--canvas-committed-scale": viewport.k,
|
||||
"--canvas-live-scale-ratio": 1,
|
||||
"--canvas-grid-size": `${48 * viewport.k}px`,
|
||||
"--canvas-grid-x": `${viewport.x % (48 * viewport.k)}px`,
|
||||
"--canvas-grid-y": `${viewport.y % (48 * viewport.k)}px`,
|
||||
@@ -393,15 +396,14 @@ export function InfiniteCanvas({ containerRef, viewport, backgroundMode = "lines
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<CanvasGrid mode={backgroundMode} />
|
||||
{graphicsLayer}
|
||||
<div
|
||||
data-canvas-world-layer
|
||||
className="absolute origin-top-left"
|
||||
style={{
|
||||
transform: "translate3d(var(--canvas-live-x), var(--canvas-live-y), 0) scale(var(--canvas-live-scale))",
|
||||
willChange: "transform",
|
||||
}}
|
||||
className="canvas-world-layer absolute origin-top-left"
|
||||
>
|
||||
{children}
|
||||
<div data-canvas-world-raster-layer className="canvas-world-raster-layer absolute origin-top-left">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
24
web/src/lib/canvas/canvas-leafer-viewport.ts
Normal file
24
web/src/lib/canvas/canvas-leafer-viewport.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ViewportTransform } from "@/types/canvas";
|
||||
|
||||
const MIN_VIEWPORT_SCALE = 0.05;
|
||||
const MIN_PREVIEW_RATIO = 0.85;
|
||||
const MAX_PREVIEW_RATIO = 1.25;
|
||||
|
||||
export function calculateCanvasPreviewTransform(viewport: ViewportTransform, rasterViewport: ViewportTransform) {
|
||||
const ratio = viewport.k / Math.max(rasterViewport.k, MIN_VIEWPORT_SCALE);
|
||||
// 将旧 backing store 的屏幕坐标映射到实时视口,避免缩放手势逐帧触发矢量重绘。
|
||||
return {
|
||||
ratio,
|
||||
x: viewport.x - rasterViewport.x * ratio,
|
||||
y: viewport.y - rasterViewport.y * ratio,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldRebaseCanvasRaster(viewport: ViewportTransform, rasterViewport: ViewportTransform) {
|
||||
const { ratio } = calculateCanvasPreviewTransform(viewport, rasterViewport);
|
||||
return ratio < MIN_PREVIEW_RATIO || ratio > MAX_PREVIEW_RATIO;
|
||||
}
|
||||
|
||||
export function sameCanvasViewport(left: ViewportTransform, right: ViewportTransform) {
|
||||
return left.x === right.x && left.y === right.y && left.k === right.k;
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { ViewportTransform } from "@/types/canvas";
|
||||
import type { SelectionBox, ViewportTransform } from "@/types/canvas";
|
||||
|
||||
export const CANVAS_VIEWPORT_PREVIEW_EVENT = "canvas:viewport-preview";
|
||||
export const CANVAS_SELECTION_PREVIEW_EVENT = "canvas:selection-preview";
|
||||
|
||||
export function applyCanvasLiveViewport(container: HTMLDivElement | null, viewport: ViewportTransform, notify = true) {
|
||||
if (!container) return;
|
||||
const gridSize = 48 * viewport.k;
|
||||
const committedScale = Number(container.style.getPropertyValue("--canvas-committed-scale")) || viewport.k;
|
||||
container.style.setProperty("--canvas-live-x", `${viewport.x}px`);
|
||||
container.style.setProperty("--canvas-live-y", `${viewport.y}px`);
|
||||
container.style.setProperty("--canvas-live-scale", String(viewport.k));
|
||||
container.style.setProperty("--canvas-live-scale-ratio", String(viewport.k / committedScale));
|
||||
container.style.setProperty("--canvas-grid-size", `${gridSize}px`);
|
||||
container.style.setProperty("--canvas-grid-x", `${viewport.x % gridSize}px`);
|
||||
container.style.setProperty("--canvas-grid-y", `${viewport.y % gridSize}px`);
|
||||
@@ -24,3 +27,13 @@ export function subscribeCanvasViewportPreview(container: HTMLDivElement, listen
|
||||
container.addEventListener(CANVAS_VIEWPORT_PREVIEW_EVENT, handlePreview);
|
||||
return () => container.removeEventListener(CANVAS_VIEWPORT_PREVIEW_EVENT, handlePreview);
|
||||
}
|
||||
|
||||
export function applyCanvasSelectionPreview(container: HTMLDivElement | null, selection: SelectionBox) {
|
||||
container?.dispatchEvent(new CustomEvent<SelectionBox>(CANVAS_SELECTION_PREVIEW_EVENT, { detail: selection }));
|
||||
}
|
||||
|
||||
export function subscribeCanvasSelectionPreview(container: HTMLDivElement, listener: (selection: SelectionBox) => void) {
|
||||
const handlePreview = (event: Event) => listener((event as CustomEvent<SelectionBox>).detail);
|
||||
container.addEventListener(CANVAS_SELECTION_PREVIEW_EVENT, handlePreview);
|
||||
return () => container.removeEventListener(CANVAS_SELECTION_PREVIEW_EVENT, handlePreview);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1278,6 +1278,21 @@
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* 静止期用最终倍率重新排版 DOM,避免 Windows Chromium 复用交互期的低分辨率合成纹理。 */
|
||||
.canvas-world-layer {
|
||||
transform: translate(var(--canvas-live-x), var(--canvas-live-y)) scale(var(--canvas-live-scale-ratio));
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.canvas-world-raster-layer {
|
||||
zoom: var(--canvas-committed-scale);
|
||||
}
|
||||
|
||||
[data-canvas-viewport-interacting="true"] .canvas-world-layer {
|
||||
transform: translate3d(var(--canvas-live-x), var(--canvas-live-y), 0) scale(var(--canvas-live-scale-ratio));
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
[data-canvas-viewport-interacting="true"] .canvas-node-shell.aceternity-comet-card,
|
||||
[data-canvas-viewport-interacting="true"] .canvas-node-shell.aceternity-comet-card:hover {
|
||||
box-shadow: none !important;
|
||||
|
||||
@@ -278,6 +278,12 @@ export type CanvasConnection = {
|
||||
toHandleId?: string;
|
||||
};
|
||||
|
||||
export type CanvasDisplayConnection = {
|
||||
connection: CanvasConnection;
|
||||
from: CanvasNodeData;
|
||||
to: CanvasNodeData;
|
||||
};
|
||||
|
||||
export type CanvasAssistantReference = {
|
||||
id: string;
|
||||
type: CanvasNodeType;
|
||||
|
||||
Reference in New Issue
Block a user