mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
762 lines
21 KiB
TypeScript
762 lines
21 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
memo,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
} from "react";
|
|
import { colors } from "./constants";
|
|
import type {
|
|
DocumentWithMemories,
|
|
GraphCanvasProps,
|
|
GraphNode,
|
|
MemoryEntry,
|
|
} from "./types";
|
|
|
|
export const GraphCanvas = memo<GraphCanvasProps>(
|
|
({
|
|
nodes,
|
|
edges,
|
|
panX,
|
|
panY,
|
|
zoom,
|
|
width,
|
|
height,
|
|
onNodeHover,
|
|
onNodeClick,
|
|
onNodeDragStart,
|
|
onNodeDragMove,
|
|
onNodeDragEnd,
|
|
onPanStart,
|
|
onPanMove,
|
|
onPanEnd,
|
|
onWheel,
|
|
onDoubleClick,
|
|
onTouchStart,
|
|
onTouchMove,
|
|
onTouchEnd,
|
|
draggingNodeId,
|
|
highlightDocumentIds,
|
|
}) => {
|
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
const animationRef = useRef<number>(0);
|
|
const startTimeRef = useRef<number>(Date.now());
|
|
const mousePos = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
|
|
const currentHoveredNode = useRef<string | null>(null);
|
|
|
|
// Initialize start time once
|
|
useEffect(() => {
|
|
startTimeRef.current = Date.now();
|
|
}, []);
|
|
|
|
// Efficient hit detection
|
|
const getNodeAtPosition = useCallback(
|
|
(x: number, y: number): string | null => {
|
|
// Check from top-most to bottom-most: memory nodes are drawn after documents
|
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
|
const node = nodes[i]!;
|
|
const screenX = node.x * zoom + panX;
|
|
const screenY = node.y * zoom + panY;
|
|
const nodeSize = node.size * zoom;
|
|
|
|
const dx = x - screenX;
|
|
const dy = y - screenY;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (distance <= nodeSize / 2) {
|
|
return node.id;
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
[nodes, panX, panY, zoom],
|
|
);
|
|
|
|
// Handle mouse events
|
|
const handleMouseMove = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
|
|
mousePos.current = { x, y };
|
|
|
|
const nodeId = getNodeAtPosition(x, y);
|
|
if (nodeId !== currentHoveredNode.current) {
|
|
currentHoveredNode.current = nodeId;
|
|
onNodeHover(nodeId);
|
|
}
|
|
|
|
// Handle node dragging
|
|
if (draggingNodeId) {
|
|
onNodeDragMove(e);
|
|
}
|
|
},
|
|
[getNodeAtPosition, onNodeHover, draggingNodeId, onNodeDragMove],
|
|
);
|
|
|
|
const handleMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
|
|
const nodeId = getNodeAtPosition(x, y);
|
|
if (nodeId) {
|
|
// When starting a node drag, prevent initiating pan
|
|
e.stopPropagation();
|
|
onNodeDragStart(nodeId, e);
|
|
return;
|
|
}
|
|
onPanStart(e);
|
|
},
|
|
[getNodeAtPosition, onNodeDragStart, onPanStart],
|
|
);
|
|
|
|
const handleClick = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const rect = canvas.getBoundingClientRect();
|
|
const x = e.clientX - rect.left;
|
|
const y = e.clientY - rect.top;
|
|
|
|
const nodeId = getNodeAtPosition(x, y);
|
|
if (nodeId) {
|
|
onNodeClick(nodeId);
|
|
}
|
|
},
|
|
[getNodeAtPosition, onNodeClick],
|
|
);
|
|
|
|
// Professional rendering function with LOD
|
|
const render = useCallback(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
if (!ctx) return;
|
|
|
|
const currentTime = Date.now();
|
|
const _elapsed = currentTime - startTimeRef.current;
|
|
|
|
// Level-of-detail optimization based on zoom
|
|
const useSimplifiedRendering = zoom < 0.3;
|
|
|
|
// Clear canvas
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
// Set high quality rendering
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.imageSmoothingQuality = "high";
|
|
|
|
// Draw minimal background grid
|
|
ctx.strokeStyle = "rgba(148, 163, 184, 0.03)"; // Very subtle grid
|
|
ctx.lineWidth = 1;
|
|
const gridSpacing = 100 * zoom;
|
|
const offsetX = panX % gridSpacing;
|
|
const offsetY = panY % gridSpacing;
|
|
|
|
// Simple, clean grid lines
|
|
for (let x = offsetX; x < width; x += gridSpacing) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x, height);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = offsetY; y < height; y += gridSpacing) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(width, y);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Create node lookup map
|
|
const nodeMap = new Map(nodes.map((node) => [node.id, node]));
|
|
|
|
// Draw enhanced edges with sophisticated styling
|
|
ctx.lineCap = "round";
|
|
edges.forEach((edge) => {
|
|
const sourceNode = nodeMap.get(edge.source);
|
|
const targetNode = nodeMap.get(edge.target);
|
|
|
|
if (sourceNode && targetNode) {
|
|
const sourceX = sourceNode.x * zoom + panX;
|
|
const sourceY = sourceNode.y * zoom + panY;
|
|
const targetX = targetNode.x * zoom + panX;
|
|
const targetY = targetNode.y * zoom + panY;
|
|
|
|
// Enhanced viewport culling with edge type considerations
|
|
if (
|
|
sourceX < -100 ||
|
|
sourceX > width + 100 ||
|
|
targetX < -100 ||
|
|
targetX > width + 100
|
|
) {
|
|
return;
|
|
}
|
|
|
|
// Skip very weak connections when zoomed out for performance
|
|
if (useSimplifiedRendering) {
|
|
if (
|
|
edge.edgeType === "doc-memory" &&
|
|
edge.visualProps.opacity < 0.3
|
|
) {
|
|
return; // Skip very weak doc-memory edges when zoomed out
|
|
}
|
|
}
|
|
|
|
// Enhanced connection styling based on edge type
|
|
let connectionColor = colors.connection.weak;
|
|
let dashPattern: number[] = [];
|
|
let opacity = edge.visualProps.opacity;
|
|
let lineWidth = Math.max(1, edge.visualProps.thickness * zoom);
|
|
|
|
if (edge.edgeType === "doc-memory") {
|
|
// Doc-memory: Solid thin lines, subtle
|
|
dashPattern = [];
|
|
connectionColor = colors.connection.memory;
|
|
opacity = 0.9;
|
|
lineWidth = 1;
|
|
} else if (edge.edgeType === "doc-doc") {
|
|
// Doc-doc: Thick dashed lines with strong similarity emphasis
|
|
dashPattern = useSimplifiedRendering ? [] : [10, 5]; // Solid lines when zoomed out
|
|
opacity = Math.max(0, edge.similarity * 0.5);
|
|
lineWidth = Math.max(1, edge.similarity * 2); // Thicker for stronger similarity
|
|
|
|
if (edge.similarity > 0.85)
|
|
connectionColor = colors.connection.strong;
|
|
else if (edge.similarity > 0.725)
|
|
connectionColor = colors.connection.medium;
|
|
} else if (edge.edgeType === "version") {
|
|
// Version chains: Double line effect with relation-specific colors
|
|
dashPattern = [];
|
|
connectionColor = edge.color || colors.relations.updates;
|
|
opacity = 0.8;
|
|
lineWidth = 2;
|
|
}
|
|
|
|
ctx.strokeStyle = connectionColor;
|
|
ctx.lineWidth = lineWidth;
|
|
ctx.globalAlpha = opacity;
|
|
ctx.setLineDash(dashPattern);
|
|
|
|
if (edge.edgeType === "version") {
|
|
// Special double-line rendering for version chains
|
|
// First line (outer)
|
|
ctx.lineWidth = 3;
|
|
ctx.globalAlpha = opacity * 0.3;
|
|
ctx.beginPath();
|
|
ctx.moveTo(sourceX, sourceY);
|
|
ctx.lineTo(targetX, targetY);
|
|
ctx.stroke();
|
|
|
|
// Second line (inner)
|
|
ctx.lineWidth = 1;
|
|
ctx.globalAlpha = opacity;
|
|
ctx.beginPath();
|
|
ctx.moveTo(sourceX, sourceY);
|
|
ctx.lineTo(targetX, targetY);
|
|
ctx.stroke();
|
|
} else {
|
|
// Simplified lines when zoomed out, curved when zoomed in
|
|
if (useSimplifiedRendering) {
|
|
// Straight lines for performance
|
|
ctx.beginPath();
|
|
ctx.moveTo(sourceX, sourceY);
|
|
ctx.lineTo(targetX, targetY);
|
|
ctx.stroke();
|
|
} else {
|
|
// Regular curved line for doc-memory and doc-doc
|
|
const midX = (sourceX + targetX) / 2;
|
|
const midY = (sourceY + targetY) / 2;
|
|
const dx = targetX - sourceX;
|
|
const dy = targetY - sourceY;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
const controlOffset =
|
|
edge.edgeType === "doc-memory"
|
|
? 15
|
|
: Math.min(30, distance * 0.2);
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(sourceX, sourceY);
|
|
ctx.quadraticCurveTo(
|
|
midX + controlOffset * (dy / distance),
|
|
midY - controlOffset * (dx / distance),
|
|
targetX,
|
|
targetY,
|
|
);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
// Subtle arrow head for version edges
|
|
if (edge.edgeType === "version") {
|
|
const angle = Math.atan2(targetY - sourceY, targetX - sourceX);
|
|
const arrowLength = Math.max(6, 8 * zoom); // Shorter, more subtle
|
|
const arrowWidth = Math.max(8, 12 * zoom);
|
|
|
|
// Calculate arrow position offset from node edge
|
|
const nodeRadius = (targetNode.size * zoom) / 2;
|
|
const offsetDistance = nodeRadius + 2;
|
|
const arrowX = targetX - Math.cos(angle) * offsetDistance;
|
|
const arrowY = targetY - Math.sin(angle) * offsetDistance;
|
|
|
|
ctx.save();
|
|
ctx.translate(arrowX, arrowY);
|
|
ctx.rotate(angle);
|
|
ctx.setLineDash([]);
|
|
|
|
// Simple outlined arrow (not filled)
|
|
ctx.strokeStyle = connectionColor;
|
|
ctx.lineWidth = Math.max(1, 1.5 * zoom);
|
|
ctx.globalAlpha = opacity;
|
|
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(-arrowLength, arrowWidth / 2);
|
|
ctx.moveTo(0, 0);
|
|
ctx.lineTo(-arrowLength, -arrowWidth / 2);
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|
|
}
|
|
});
|
|
|
|
ctx.globalAlpha = 1;
|
|
ctx.setLineDash([]);
|
|
|
|
// Prepare highlight set from provided document IDs (customId or internal)
|
|
const highlightSet = new Set<string>(highlightDocumentIds ?? []);
|
|
|
|
// Draw nodes with enhanced styling and LOD optimization
|
|
nodes.forEach((node) => {
|
|
const screenX = node.x * zoom + panX;
|
|
const screenY = node.y * zoom + panY;
|
|
const nodeSize = node.size * zoom;
|
|
|
|
// Enhanced viewport culling
|
|
const margin = nodeSize + 50;
|
|
if (
|
|
screenX < -margin ||
|
|
screenX > width + margin ||
|
|
screenY < -margin ||
|
|
screenY > height + margin
|
|
) {
|
|
return;
|
|
}
|
|
|
|
const isHovered = currentHoveredNode.current === node.id;
|
|
const isDragging = node.isDragging;
|
|
const isHighlightedDocument = (() => {
|
|
if (node.type !== "document" || highlightSet.size === 0) return false;
|
|
const doc = node.data as DocumentWithMemories;
|
|
if (doc.customId && highlightSet.has(doc.customId)) return true;
|
|
return highlightSet.has(doc.id);
|
|
})();
|
|
|
|
if (node.type === "document") {
|
|
// Enhanced glassmorphism document styling
|
|
const docWidth = nodeSize * 1.4;
|
|
const docHeight = nodeSize * 0.9;
|
|
|
|
// Multi-layer glass effect
|
|
ctx.fillStyle = isDragging
|
|
? colors.document.accent
|
|
: isHovered
|
|
? colors.document.secondary
|
|
: colors.document.primary;
|
|
ctx.globalAlpha = 1;
|
|
|
|
// Enhanced border with subtle glow
|
|
ctx.strokeStyle = isDragging
|
|
? colors.document.glow
|
|
: isHovered
|
|
? colors.document.accent
|
|
: colors.document.border;
|
|
ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1;
|
|
|
|
// Rounded rectangle with enhanced styling
|
|
const radius = useSimplifiedRendering ? 6 : 12;
|
|
ctx.beginPath();
|
|
ctx.roundRect(
|
|
screenX - docWidth / 2,
|
|
screenY - docHeight / 2,
|
|
docWidth,
|
|
docHeight,
|
|
radius,
|
|
);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
// Subtle inner highlight for glass effect (skip when zoomed out)
|
|
if (!useSimplifiedRendering && (isHovered || isDragging)) {
|
|
ctx.strokeStyle = "rgba(255, 255, 255, 0.1)";
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.roundRect(
|
|
screenX - docWidth / 2 + 1,
|
|
screenY - docHeight / 2 + 1,
|
|
docWidth - 2,
|
|
docHeight - 2,
|
|
radius - 1,
|
|
);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Highlight ring for search hits
|
|
if (isHighlightedDocument) {
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.9;
|
|
ctx.strokeStyle = colors.accent.primary;
|
|
ctx.lineWidth = 3;
|
|
ctx.setLineDash([6, 4]);
|
|
const ringPadding = 10;
|
|
ctx.beginPath();
|
|
ctx.roundRect(
|
|
screenX - docWidth / 2 - ringPadding,
|
|
screenY - docHeight / 2 - ringPadding,
|
|
docWidth + ringPadding * 2,
|
|
docHeight + ringPadding * 2,
|
|
radius + 6,
|
|
);
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
ctx.restore();
|
|
}
|
|
} else {
|
|
// Enhanced memory styling with status indicators
|
|
const mem = node.data as MemoryEntry;
|
|
const isForgotten =
|
|
mem.isForgotten ||
|
|
(mem.forgetAfter &&
|
|
new Date(mem.forgetAfter).getTime() < Date.now());
|
|
const isLatest = mem.isLatest;
|
|
|
|
// Check if memory is expiring soon (within 7 days)
|
|
const expiringSoon =
|
|
mem.forgetAfter &&
|
|
!isForgotten &&
|
|
new Date(mem.forgetAfter).getTime() - Date.now() <
|
|
1000 * 60 * 60 * 24 * 7;
|
|
|
|
// Check if memory is new (created within last 24 hours)
|
|
const isNew =
|
|
!isForgotten &&
|
|
new Date(mem.createdAt).getTime() >
|
|
Date.now() - 1000 * 60 * 60 * 24;
|
|
|
|
// Determine colors based on status
|
|
let fillColor = colors.memory.primary;
|
|
let borderColor = colors.memory.border;
|
|
let glowColor = colors.memory.glow;
|
|
|
|
if (isForgotten) {
|
|
fillColor = colors.status.forgotten;
|
|
borderColor = "rgba(220,38,38,0.3)";
|
|
glowColor = "rgba(220,38,38,0.2)";
|
|
} else if (expiringSoon) {
|
|
borderColor = colors.status.expiring;
|
|
glowColor = colors.accent.amber;
|
|
} else if (isNew) {
|
|
borderColor = colors.status.new;
|
|
glowColor = colors.accent.emerald;
|
|
}
|
|
|
|
if (isDragging) {
|
|
fillColor = colors.memory.accent;
|
|
borderColor = glowColor;
|
|
} else if (isHovered) {
|
|
fillColor = colors.memory.secondary;
|
|
}
|
|
|
|
const radius = nodeSize / 2;
|
|
|
|
ctx.fillStyle = fillColor;
|
|
ctx.globalAlpha = isLatest ? 1 : 0.4;
|
|
ctx.strokeStyle = borderColor;
|
|
ctx.lineWidth = isDragging ? 3 : isHovered ? 2 : 1.5;
|
|
|
|
if (useSimplifiedRendering) {
|
|
// Simple circles when zoomed out for performance
|
|
ctx.beginPath();
|
|
ctx.arc(screenX, screenY, radius, 0, 2 * Math.PI);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
} else {
|
|
// HEXAGONAL memory nodes when zoomed in
|
|
const sides = 6;
|
|
ctx.beginPath();
|
|
for (let i = 0; i < sides; i++) {
|
|
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2; // Start from top
|
|
const x = screenX + radius * Math.cos(angle);
|
|
const y = screenY + radius * Math.sin(angle);
|
|
if (i === 0) {
|
|
ctx.moveTo(x, y);
|
|
} else {
|
|
ctx.lineTo(x, y);
|
|
}
|
|
}
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
|
|
// Inner highlight for glass effect
|
|
if (isHovered || isDragging) {
|
|
ctx.strokeStyle = "rgba(147, 197, 253, 0.3)";
|
|
ctx.lineWidth = 1;
|
|
const innerRadius = radius - 2;
|
|
ctx.beginPath();
|
|
for (let i = 0; i < sides; i++) {
|
|
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2;
|
|
const x = screenX + innerRadius * Math.cos(angle);
|
|
const y = screenY + innerRadius * Math.sin(angle);
|
|
if (i === 0) {
|
|
ctx.moveTo(x, y);
|
|
} else {
|
|
ctx.lineTo(x, y);
|
|
}
|
|
}
|
|
ctx.closePath();
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
// Status indicators overlay (always preserve these as required)
|
|
if (isForgotten) {
|
|
// Cross for forgotten memories
|
|
ctx.strokeStyle = "rgba(220,38,38,0.4)";
|
|
ctx.lineWidth = 2;
|
|
const r = nodeSize * 0.25;
|
|
ctx.beginPath();
|
|
ctx.moveTo(screenX - r, screenY - r);
|
|
ctx.lineTo(screenX + r, screenY + r);
|
|
ctx.moveTo(screenX + r, screenY - r);
|
|
ctx.lineTo(screenX - r, screenY + r);
|
|
ctx.stroke();
|
|
} else if (isNew) {
|
|
// Small dot for new memories
|
|
ctx.fillStyle = colors.status.new;
|
|
ctx.beginPath();
|
|
ctx.arc(
|
|
screenX + nodeSize * 0.25,
|
|
screenY - nodeSize * 0.25,
|
|
Math.max(2, nodeSize * 0.15), // Scale with node size, minimum 2px
|
|
0,
|
|
2 * Math.PI,
|
|
);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
// Enhanced hover glow effect (skip when zoomed out for performance)
|
|
if (!useSimplifiedRendering && (isHovered || isDragging)) {
|
|
const glowColor =
|
|
node.type === "document"
|
|
? colors.document.glow
|
|
: colors.memory.glow;
|
|
|
|
ctx.strokeStyle = glowColor;
|
|
ctx.lineWidth = 1;
|
|
ctx.setLineDash([3, 3]);
|
|
ctx.globalAlpha = 0.6;
|
|
|
|
ctx.beginPath();
|
|
const glowSize = nodeSize * 0.7;
|
|
if (node.type === "document") {
|
|
ctx.roundRect(
|
|
screenX - glowSize,
|
|
screenY - glowSize / 1.4,
|
|
glowSize * 2,
|
|
glowSize * 1.4,
|
|
15,
|
|
);
|
|
} else {
|
|
// Hexagonal glow for memory nodes
|
|
const glowRadius = glowSize;
|
|
const sides = 6;
|
|
for (let i = 0; i < sides; i++) {
|
|
const angle = (i * 2 * Math.PI) / sides - Math.PI / 2;
|
|
const x = screenX + glowRadius * Math.cos(angle);
|
|
const y = screenY + glowRadius * Math.sin(angle);
|
|
if (i === 0) {
|
|
ctx.moveTo(x, y);
|
|
} else {
|
|
ctx.lineTo(x, y);
|
|
}
|
|
}
|
|
ctx.closePath();
|
|
}
|
|
ctx.stroke();
|
|
ctx.setLineDash([]);
|
|
}
|
|
});
|
|
|
|
ctx.globalAlpha = 1;
|
|
}, [nodes, edges, panX, panY, zoom, width, height, highlightDocumentIds]);
|
|
|
|
// Change-based rendering instead of continuous animation
|
|
const lastRenderParams = useRef<string>("");
|
|
|
|
// Create a render key that changes when visual state changes
|
|
const renderKey = useMemo(() => {
|
|
const nodePositions = nodes
|
|
.map(
|
|
(n) =>
|
|
`${n.id}:${n.x}:${n.y}:${n.isDragging ? "1" : "0"}:${currentHoveredNode.current === n.id ? "1" : "0"}`,
|
|
)
|
|
.join("|");
|
|
const highlightKey = (highlightDocumentIds ?? []).join("|");
|
|
return `${nodePositions}-${edges.length}-${panX}-${panY}-${zoom}-${width}-${height}-${highlightKey}`;
|
|
}, [
|
|
nodes,
|
|
edges.length,
|
|
panX,
|
|
panY,
|
|
zoom,
|
|
width,
|
|
height,
|
|
highlightDocumentIds,
|
|
]);
|
|
|
|
// Only render when something actually changed
|
|
useEffect(() => {
|
|
if (renderKey !== lastRenderParams.current) {
|
|
lastRenderParams.current = renderKey;
|
|
render();
|
|
}
|
|
}, [renderKey, render]);
|
|
|
|
// Cleanup any existing animation frames
|
|
useEffect(() => {
|
|
return () => {
|
|
if (animationRef.current) {
|
|
cancelAnimationFrame(animationRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
// Add native wheel event listener to prevent browser zoom
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
const handleNativeWheel = (e: WheelEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// Call the onWheel handler with a synthetic-like event
|
|
onWheel({
|
|
deltaY: e.deltaY,
|
|
deltaX: e.deltaX,
|
|
clientX: e.clientX,
|
|
clientY: e.clientY,
|
|
currentTarget: canvas,
|
|
nativeEvent: e,
|
|
preventDefault: () => {},
|
|
stopPropagation: () => {},
|
|
} as React.WheelEvent);
|
|
};
|
|
|
|
// Add listener with passive: false to ensure preventDefault works
|
|
canvas.addEventListener("wheel", handleNativeWheel, { passive: false });
|
|
|
|
// Also prevent gesture events for touch devices
|
|
const handleGesture = (e: Event) => {
|
|
e.preventDefault();
|
|
};
|
|
|
|
canvas.addEventListener("gesturestart", handleGesture, {
|
|
passive: false,
|
|
});
|
|
canvas.addEventListener("gesturechange", handleGesture, {
|
|
passive: false,
|
|
});
|
|
canvas.addEventListener("gestureend", handleGesture, { passive: false });
|
|
|
|
return () => {
|
|
canvas.removeEventListener("wheel", handleNativeWheel);
|
|
canvas.removeEventListener("gesturestart", handleGesture);
|
|
canvas.removeEventListener("gesturechange", handleGesture);
|
|
canvas.removeEventListener("gestureend", handleGesture);
|
|
};
|
|
}, [onWheel]);
|
|
|
|
// High-DPI handling --------------------------------------------------
|
|
const dpr =
|
|
typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
|
|
|
|
useLayoutEffect(() => {
|
|
const canvas = canvasRef.current;
|
|
if (!canvas) return;
|
|
|
|
// upscale backing store
|
|
canvas.style.width = `${width}px`;
|
|
canvas.style.height = `${height}px`;
|
|
canvas.width = width * dpr;
|
|
canvas.height = height * dpr;
|
|
|
|
const ctx = canvas.getContext("2d");
|
|
ctx?.scale(dpr, dpr);
|
|
}, [width, height, dpr]);
|
|
// -----------------------------------------------------------------------
|
|
|
|
return (
|
|
<canvas
|
|
className="absolute inset-0"
|
|
height={height}
|
|
onClick={handleClick}
|
|
onDoubleClick={onDoubleClick}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseLeave={() => {
|
|
if (draggingNodeId) {
|
|
onNodeDragEnd();
|
|
} else {
|
|
onPanEnd();
|
|
}
|
|
}}
|
|
onMouseMove={(e) => {
|
|
handleMouseMove(e);
|
|
if (!draggingNodeId) {
|
|
onPanMove(e);
|
|
}
|
|
}}
|
|
onMouseUp={() => {
|
|
if (draggingNodeId) {
|
|
onNodeDragEnd();
|
|
} else {
|
|
onPanEnd();
|
|
}
|
|
}}
|
|
onTouchStart={onTouchStart}
|
|
onTouchMove={onTouchMove}
|
|
onTouchEnd={onTouchEnd}
|
|
ref={canvasRef}
|
|
style={{
|
|
cursor: draggingNodeId
|
|
? "grabbing"
|
|
: currentHoveredNode.current
|
|
? "grab"
|
|
: "move",
|
|
touchAction: "none",
|
|
userSelect: "none",
|
|
WebkitUserSelect: "none",
|
|
}}
|
|
width={width}
|
|
/>
|
|
);
|
|
},
|
|
);
|
|
|
|
GraphCanvas.displayName = "GraphCanvas";
|