466 lines
21 KiB
TypeScript
466 lines
21 KiB
TypeScript
import { useLayoutEffect, useRef, type RefObject } from "react";
|
||
import { Group, Leafer, Path, Rect } from "leafer-ui";
|
||
|
||
import { activeConnectionPath, canvasConnectionPath } from "@/components/canvas/canvas-connections";
|
||
import type { CanvasBatchConnectionPreview } from "@/lib/canvas/canvas-batch-connection";
|
||
import { subscribeCanvasGraphicsViewportPreview, subscribeCanvasNodeDragPreview, subscribeCanvasSelectionPreview, type CanvasNodeDragPreview } 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;
|
||
batchConnectionPreview: CanvasBatchConnectionPreview | null;
|
||
mouseWorld: Position;
|
||
connectionTargetNodeId: string | null;
|
||
connectionTargetAnchorRatio?: number;
|
||
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;
|
||
connectionEntries: Map<string, ConnectionSceneEntry>;
|
||
connectionIdsByNodeId: Map<string, Set<string>>;
|
||
dragPreview: CanvasNodeDragPreview | null;
|
||
dragPreviewConnectionIds: Set<string>;
|
||
};
|
||
|
||
type ConnectionSceneEntry = {
|
||
path: Path;
|
||
connection: CanvasDisplayConnection["connection"];
|
||
from: CanvasNodeData;
|
||
to: CanvasNodeData;
|
||
signature: string;
|
||
};
|
||
|
||
type OverlayScene = LeaferScene & {
|
||
selection: Rect;
|
||
selectionBounds: Rect;
|
||
guides: Path;
|
||
draft: Path;
|
||
batchDrafts: Group;
|
||
};
|
||
|
||
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 (isViewportPreview(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 = subscribeCanvasGraphicsViewportPreview(container, (next) => {
|
||
viewportRef.current = next;
|
||
const rect = container.getBoundingClientRect();
|
||
if (isViewportPreview(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);
|
||
});
|
||
const unsubscribeNodeDrag = subscribeCanvasNodeDragPreview(container, (preview) => {
|
||
applyConnectionDragPreview(underlay, propsRef.current, preview);
|
||
});
|
||
resize();
|
||
|
||
return () => {
|
||
unsubscribe();
|
||
unsubscribeSelection();
|
||
unsubscribeNodeDrag();
|
||
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.batchConnectionPreview, props.connectingParams, props.connectionTargetAnchorRatio, 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 (isViewportPreview(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-[var(--z-canvas-overlay)] 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, connectionEntries: new Map(), connectionIdsByNodeId: new Map(), dragPreview: null, dragPreviewConnectionIds: new Set() };
|
||
}
|
||
|
||
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 });
|
||
const batchDrafts = new Group({ visible: false, hittable: false });
|
||
world.add(selection);
|
||
world.add(selectionBounds);
|
||
world.add(guides);
|
||
world.add(draft);
|
||
world.add(batchDrafts);
|
||
leafer.add(world);
|
||
return { leafer, world, host, selection, selectionBounds, guides, draft, batchDrafts };
|
||
}
|
||
|
||
function rebuildConnections(scene: UnderlayScene, props: CanvasLeaferGraphicsLayerProps) {
|
||
const nextIds = new Set(props.displayConnections.map(({ connection }) => connection.id));
|
||
for (const [connectionId, entry] of scene.connectionEntries) {
|
||
if (nextIds.has(connectionId)) continue;
|
||
entry.path.remove();
|
||
scene.connectionEntries.delete(connectionId);
|
||
}
|
||
|
||
scene.connectionIdsByNodeId.clear();
|
||
const previewIds = scene.dragPreview ? scene.dragPreview.nodeIds : null;
|
||
for (const { connection, from, to } of props.displayConnections) {
|
||
const emphasized = props.selectedConnectionId === connection.id || props.relatedConnectionIds.has(connection.id);
|
||
const signature = connectionSceneSignature(connection, from, to, props, emphasized);
|
||
let entry = scene.connectionEntries.get(connection.id);
|
||
if (!entry) {
|
||
const path = new Path({ hittable: false });
|
||
scene.connections.add(path);
|
||
entry = { path, connection, from, to, signature: "" };
|
||
scene.connectionEntries.set(connection.id, entry);
|
||
}
|
||
entry.connection = connection;
|
||
entry.from = from;
|
||
entry.to = to;
|
||
if (entry.signature !== signature || scene.dragPreview) {
|
||
syncConnectionPath(entry, props, scene.dragPreview, previewIds);
|
||
entry.signature = signature;
|
||
}
|
||
for (const nodeId of [from.id, to.id]) {
|
||
const connectionIds = scene.connectionIdsByNodeId.get(nodeId) || new Set<string>();
|
||
connectionIds.add(connection.id);
|
||
scene.connectionIdsByNodeId.set(nodeId, connectionIds);
|
||
}
|
||
}
|
||
|
||
scene.dragPreviewConnectionIds = collectPreviewConnectionIds(scene, scene.dragPreview);
|
||
}
|
||
|
||
function connectionSceneSignature(connection: CanvasDisplayConnection["connection"], from: CanvasNodeData, to: CanvasNodeData, props: CanvasLeaferGraphicsLayerProps, emphasized: boolean) {
|
||
return [
|
||
connection.id,
|
||
connection.fromNodeId,
|
||
connection.toNodeId,
|
||
connection.fromHandleId || "",
|
||
connection.toHandleId || "",
|
||
from.id,
|
||
from.position.x,
|
||
from.position.y,
|
||
from.width,
|
||
from.height,
|
||
to.id,
|
||
to.position.x,
|
||
to.position.y,
|
||
to.width,
|
||
to.height,
|
||
props.scriptScrollTopById[from.id] || 0,
|
||
props.scriptScrollTopById[to.id] || 0,
|
||
emphasized ? "active" : "idle",
|
||
props.theme.accent.primary,
|
||
props.theme.node.muted,
|
||
].join("|");
|
||
}
|
||
|
||
function syncConnectionPath(entry: ConnectionSceneEntry, props: CanvasLeaferGraphicsLayerProps, preview: CanvasNodeDragPreview | null, previewIds: ReadonlySet<string> | null = preview?.nodeIds || null) {
|
||
const from = translatePreviewNode(entry.from, previewIds, preview);
|
||
const to = translatePreviewNode(entry.to, previewIds, preview);
|
||
const emphasized = props.selectedConnectionId === entry.connection.id || props.relatedConnectionIds.has(entry.connection.id);
|
||
entry.path.set({
|
||
path: canvasConnectionPath(entry.connection, from, to, props.scriptScrollTopById[entry.from.id] || 0, props.scriptScrollTopById[entry.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,
|
||
});
|
||
}
|
||
|
||
function translatePreviewNode(node: CanvasNodeData, previewIds: ReadonlySet<string> | null, preview: CanvasNodeDragPreview | null) {
|
||
if (!preview || !previewIds?.has(node.id) || (preview.x === 0 && preview.y === 0)) return node;
|
||
return { ...node, position: { x: node.position.x + preview.x, y: node.position.y + preview.y } };
|
||
}
|
||
|
||
function collectPreviewConnectionIds(scene: UnderlayScene, preview: CanvasNodeDragPreview | null) {
|
||
const connectionIds = new Set<string>();
|
||
if (!preview) return connectionIds;
|
||
for (const nodeId of preview.nodeIds) {
|
||
scene.connectionIdsByNodeId.get(nodeId)?.forEach((connectionId) => connectionIds.add(connectionId));
|
||
}
|
||
return connectionIds;
|
||
}
|
||
|
||
function applyConnectionDragPreview(scene: UnderlayScene, props: CanvasLeaferGraphicsLayerProps, preview: CanvasNodeDragPreview | null) {
|
||
const affectedConnectionIds = new Set(scene.dragPreviewConnectionIds);
|
||
const previewIds = preview?.nodeIds || null;
|
||
collectPreviewConnectionIds(scene, preview).forEach((connectionId) => affectedConnectionIds.add(connectionId));
|
||
scene.dragPreview = preview;
|
||
scene.dragPreviewConnectionIds = collectPreviewConnectionIds(scene, preview);
|
||
for (const connectionId of affectedConnectionIds) {
|
||
const entry = scene.connectionEntries.get(connectionId);
|
||
if (!entry) continue;
|
||
syncConnectionPath(entry, props, preview, previewIds);
|
||
}
|
||
}
|
||
|
||
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.node.label;
|
||
}
|
||
|
||
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,
|
||
});
|
||
}
|
||
|
||
scene.batchDrafts.removeAll(true);
|
||
const batch = props.batchConnectionPreview;
|
||
scene.batchDrafts.visible = Boolean(batch);
|
||
if (!batch) return;
|
||
const target = batch.targetNodeId ? props.nodeById.get(batch.targetNodeId) : undefined;
|
||
const stroke = batch.status === "invalid" ? props.theme.accent.danger : batch.status === "partial" ? props.theme.node.activeStroke : props.theme.accent.primary;
|
||
batch.sourceNodeIds.forEach((sourceNodeId) => {
|
||
const source = props.nodeById.get(sourceNodeId);
|
||
if (!source) return;
|
||
const handle: ConnectionHandle = { nodeId: source.id, handleType: "source" };
|
||
scene.batchDrafts.add(new Path({
|
||
path: activeConnectionPath(source, handle, batch.mouseWorld, target, props.scriptScrollTopById[source.id] || 0),
|
||
stroke,
|
||
strokeWidth: 1.4,
|
||
strokeScaleFixed: true,
|
||
strokeCap: "round",
|
||
dashPattern: [8, 8],
|
||
opacity: 0.72,
|
||
hittable: false,
|
||
}));
|
||
});
|
||
}
|
||
|
||
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: "transparent",
|
||
stroke: theme.node.label,
|
||
opacity: selection.hitMode === "intersect" ? 0.82 : 0.68,
|
||
});
|
||
}
|
||
|
||
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.set({
|
||
strokeWidth: 1 / scale,
|
||
cornerRadius: 2 / scale,
|
||
dashPattern: [4 / scale, 4 / scale],
|
||
});
|
||
if (props.selectedNodeBounds) syncSelectionBounds(overlay.selectionBounds, props.selectedNodeBounds, scale);
|
||
overlay.selectionBounds.set({
|
||
strokeWidth: 1 / scale,
|
||
cornerRadius: 2 / scale,
|
||
dashPattern: [4 / scale, 4 / scale],
|
||
opacity: 0.68,
|
||
});
|
||
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 isViewportPreview(container: HTMLDivElement, viewport: ViewportTransform, rasterViewport: ViewportTransform) {
|
||
return container.dataset.canvasViewportInteracting === "true" && !sameCanvasViewport(viewport, rasterViewport);
|
||
}
|
||
|
||
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));
|
||
}
|