mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-11-05 07:39:44 +00:00
971 lines
27 KiB
TypeScript
971 lines
27 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react"
|
|
import {
|
|
DENSE_GRAPH_STATIC_THRESHOLD,
|
|
ForceSimulation,
|
|
} from "../canvas/simulation"
|
|
import { DEFAULT_HOVER_POPOVER_Z_INDEX, DEFAULT_LABELS } from "../constants"
|
|
import { VersionChainIndex } from "../canvas/version-chain"
|
|
import type { ViewportState } from "../canvas/viewport"
|
|
import { useGraphData } from "../hooks/use-graph-data"
|
|
import { useGraphTheme } from "../hooks/use-graph-theme"
|
|
import { useInitialGraphFit } from "../hooks/use-initial-graph-fit"
|
|
import type {
|
|
GraphApiDocument,
|
|
GraphNode,
|
|
MemoryNodeData,
|
|
MemoryGraphProps,
|
|
ResolvedMemoryGraphLabels,
|
|
} from "../types"
|
|
import { GraphCanvas } from "./graph-canvas"
|
|
import { Legend } from "./legend"
|
|
import { LoadingIndicator } from "./loading-indicator"
|
|
import { NavigationControls } from "./navigation-controls"
|
|
import { NodeHoverPopover } from "./node-hover-popover"
|
|
import { Minimap } from "./minimap"
|
|
|
|
function documentIdForNode(
|
|
node: GraphNode | null,
|
|
documents: GraphApiDocument[],
|
|
): string | null {
|
|
if (!node || typeof node.data.id !== "string" || !node.data.id.trim())
|
|
return null
|
|
if (node.type === "document") {
|
|
return documents.some((document) => document.id === node.data.id)
|
|
? node.data.id
|
|
: null
|
|
}
|
|
const sourceId = (node.data as MemoryNodeData).documentId
|
|
if (typeof sourceId !== "string" || !sourceId.trim()) return null
|
|
const ids = new Set(
|
|
documents
|
|
.filter(
|
|
(document) =>
|
|
document.id.trim() &&
|
|
document.memories.some((memory) => memory.id === node.data.id),
|
|
)
|
|
.map((document) => document.id),
|
|
)
|
|
return ids.size === 1 && ids.has(sourceId) ? sourceId : null
|
|
}
|
|
|
|
export function MemoryGraph({
|
|
documents = [],
|
|
isLoading: externalIsLoading = false,
|
|
isLoadingMore = false,
|
|
onLoadMore,
|
|
hasMore = false,
|
|
error: externalError = null,
|
|
children,
|
|
variant = "console",
|
|
highlightDocumentIds = [],
|
|
highlightsVisible = true,
|
|
showFps = false,
|
|
showMinimap = false,
|
|
maxNodes,
|
|
isSlideshowActive = false,
|
|
onSlideshowNodeChange,
|
|
canvasRef: externalCanvasRef,
|
|
colors: colorOverrides,
|
|
totalCount,
|
|
onOpenDocument: externalOnOpenDocument,
|
|
onNodeSelect,
|
|
labels: labelOverrides,
|
|
layering,
|
|
appearance,
|
|
openDocumentOnEnter = false,
|
|
}: MemoryGraphProps) {
|
|
const resolvedLabels = useMemo<ResolvedMemoryGraphLabels>(
|
|
() => ({ ...DEFAULT_LABELS, ...labelOverrides }),
|
|
[labelOverrides],
|
|
)
|
|
const hoverPopoverZIndex =
|
|
layering?.hoverPopoverZIndex ?? DEFAULT_HOVER_POPOVER_Z_INDEX
|
|
const colors = useGraphTheme(colorOverrides)
|
|
|
|
const [containerSize, setContainerSize] = useState({ width: 0, height: 0 })
|
|
const [containerBounds, setContainerBounds] = useState<DOMRect | null>(null)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const viewportRef = useRef<ViewportState | null>(null)
|
|
const simulationRef = useRef<ForceSimulation | null>(null)
|
|
const [simulation, setSimulation] = useState<ForceSimulation | null>(null)
|
|
const chainIndex = useRef(new VersionChainIndex())
|
|
|
|
// React state only for things that affect DOM
|
|
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
|
|
const [selectedNode, setSelectedNode] = useState<string | null>(null)
|
|
const onNodeSelectRef = useRef(onNodeSelect)
|
|
onNodeSelectRef.current = onNodeSelect
|
|
const lastSelectedRef = useRef(selectedNode)
|
|
// Compares against the last value so mount and StrictMode re-runs do not emit.
|
|
useEffect(() => {
|
|
if (lastSelectedRef.current === selectedNode) return
|
|
lastSelectedRef.current = selectedNode
|
|
onNodeSelectRef.current?.(selectedNode)
|
|
}, [selectedNode])
|
|
const [zoomDisplay, setZoomDisplay] = useState(50)
|
|
// Monotonic counter that increments on any viewport change (pan or zoom)
|
|
// Used as a dependency proxy to recalculate popover positions
|
|
const [viewportVersion, setViewportVersion] = useState(0)
|
|
|
|
// Cap documents + unique memories at maxNodes; shared memories count once.
|
|
const limitedDocuments = useMemo(() => {
|
|
if (!maxNodes || documents.length === 0) return documents
|
|
let totalNodes = 0
|
|
const seenMemoryIds = new Set<string>()
|
|
const limited: GraphApiDocument[] = []
|
|
for (const doc of documents) {
|
|
if (totalNodes >= maxNodes) break
|
|
|
|
const memories = doc.memories ?? []
|
|
const includedMemories: GraphApiDocument["memories"] = []
|
|
totalNodes += 1
|
|
|
|
for (const memory of memories) {
|
|
if (seenMemoryIds.has(memory.id)) {
|
|
includedMemories.push(memory)
|
|
continue
|
|
}
|
|
|
|
if (totalNodes >= maxNodes) continue
|
|
|
|
seenMemoryIds.add(memory.id)
|
|
includedMemories.push(memory)
|
|
totalNodes += 1
|
|
}
|
|
|
|
limited.push(
|
|
includedMemories.length === memories.length
|
|
? doc
|
|
: { ...doc, memories: includedMemories },
|
|
)
|
|
}
|
|
return limited
|
|
}, [documents, maxNodes])
|
|
|
|
const hasContainerSize = containerSize.width > 0 && containerSize.height > 0
|
|
|
|
const { nodes, edges } = useGraphData(
|
|
hasContainerSize ? limitedDocuments : [],
|
|
null,
|
|
containerSize.width,
|
|
containerSize.height,
|
|
colors,
|
|
variant === "console" ? "theme" : "cluster",
|
|
)
|
|
const isCompactViewport = containerSize.width > 0 && containerSize.width < 640
|
|
const graphFitHeight = isCompactViewport
|
|
? Math.max(containerSize.height - 170, 240)
|
|
: containerSize.height
|
|
const compactLegendMaxHeight = isCompactViewport
|
|
? Math.max(containerSize.height - 104, 160)
|
|
: undefined
|
|
|
|
// Rebuild version chain index during render (not in an effect) so that
|
|
// the chain data is up-to-date when getChain() is called in useMemo below.
|
|
// rebuild() has an early-return guard (referential equality on documents)
|
|
// that makes this a no-op on re-renders where limitedDocuments hasn't changed.
|
|
chainIndex.current.rebuild(limitedDocuments)
|
|
|
|
const prevSimIdsRef = useRef<Set<string>>(new Set())
|
|
useLayoutEffect(() => {
|
|
if (nodes.length === 0) {
|
|
simulationRef.current?.destroy()
|
|
simulationRef.current = null
|
|
setSimulation(null)
|
|
prevSimIdsRef.current = new Set()
|
|
return
|
|
}
|
|
|
|
const currentIds = new Set(nodes.map((n) => n.id))
|
|
if (nodes.length > DENSE_GRAPH_STATIC_THRESHOLD) {
|
|
simulationRef.current?.destroy()
|
|
simulationRef.current = null
|
|
setSimulation(null)
|
|
prevSimIdsRef.current = currentIds
|
|
return
|
|
}
|
|
|
|
const previousIds = prevSimIdsRef.current
|
|
const hasPreviousIds = previousIds.size > 0
|
|
const idsChanged =
|
|
currentIds.size !== previousIds.size ||
|
|
[...currentIds].some((id) => !previousIds.has(id))
|
|
const isAppendOnly =
|
|
hasPreviousIds && [...previousIds].every((id) => currentIds.has(id))
|
|
|
|
if (!simulationRef.current) {
|
|
const sim = new ForceSimulation()
|
|
simulationRef.current = sim
|
|
setSimulation(sim)
|
|
}
|
|
|
|
if (!hasPreviousIds || (idsChanged && !isAppendOnly)) {
|
|
prevSimIdsRef.current = currentIds
|
|
simulationRef.current.init(nodes, edges)
|
|
} else if (idsChanged && isAppendOnly) {
|
|
prevSimIdsRef.current = currentIds
|
|
simulationRef.current.update(nodes, edges)
|
|
simulationRef.current.settle()
|
|
} else {
|
|
simulationRef.current.update(nodes, edges)
|
|
}
|
|
}, [nodes, edges])
|
|
|
|
// Cleanup simulation on unmount
|
|
useEffect(() => {
|
|
return () => {
|
|
simulationRef.current?.destroy()
|
|
simulationRef.current = null
|
|
setSimulation(null)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
viewportRef.current?.setMinZoomForNodes(
|
|
nodes,
|
|
containerSize.width,
|
|
graphFitHeight,
|
|
)
|
|
}, [nodes, containerSize.width, graphFitHeight])
|
|
|
|
const stopFollowing = useInitialGraphFit({
|
|
documents: limitedDocuments,
|
|
nodes,
|
|
viewportRef,
|
|
simulationRef,
|
|
width: containerSize.width,
|
|
height: hasContainerSize ? graphFitHeight : 0,
|
|
isLoading: externalIsLoading,
|
|
isLoadingMore,
|
|
hasMore,
|
|
})
|
|
|
|
// Container resize observer
|
|
useEffect(() => {
|
|
const el = containerRef.current
|
|
if (!el) return
|
|
|
|
const measure = () => {
|
|
const rect = el.getBoundingClientRect()
|
|
const width = Math.round(rect.width) || el.clientWidth
|
|
const height = Math.round(rect.height) || el.clientHeight
|
|
setContainerSize({ width, height })
|
|
setContainerBounds(rect)
|
|
}
|
|
|
|
const ro = new ResizeObserver(measure)
|
|
ro.observe(el)
|
|
const parent = el.parentElement
|
|
if (parent) ro.observe(parent)
|
|
measure()
|
|
const raf = requestAnimationFrame(measure)
|
|
return () => {
|
|
cancelAnimationFrame(raf)
|
|
ro.disconnect()
|
|
}
|
|
}, [])
|
|
|
|
// Callbacks for GraphCanvas
|
|
const handleNodeHover = useCallback(
|
|
(id: string | null) => setHoveredNode(id),
|
|
[],
|
|
)
|
|
|
|
const handleNodeClick = useCallback((id: string | null) => {
|
|
setSelectedNode((prev) => (id === null ? null : prev === id ? null : id))
|
|
}, [])
|
|
|
|
const handleNodeDragStart = useCallback((_id: string) => {
|
|
// Drag is handled imperatively by InputHandler
|
|
}, [])
|
|
|
|
const handleNodeDragEnd = useCallback(() => {
|
|
// Drag end handled by InputHandler
|
|
}, [])
|
|
|
|
// Load more when user zooms out enough that visible area dwarfs the node extent
|
|
const loadMoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const loadMoreRef = useRef({ hasMore, isLoadingMore, onLoadMore })
|
|
loadMoreRef.current = { hasMore, isLoadingMore, onLoadMore }
|
|
const handleViewportChange = useCallback(
|
|
(zoom: number, popoverVisible: boolean) => {
|
|
setZoomDisplay(Math.round(zoom * 100))
|
|
if (popoverVisible) {
|
|
setViewportVersion((v) => v + 1)
|
|
}
|
|
|
|
const {
|
|
hasMore: more,
|
|
isLoadingMore: loading,
|
|
onLoadMore: load,
|
|
} = loadMoreRef.current
|
|
if (!more || loading || !load || !viewportRef.current) return
|
|
|
|
const vp = viewportRef.current
|
|
const currentNodes = nodes
|
|
if (currentNodes.length === 0) return
|
|
|
|
const topLeft = vp.screenToWorld(0, 0)
|
|
const bottomRight = vp.screenToWorld(
|
|
containerSize.width,
|
|
containerSize.height,
|
|
)
|
|
const viewW = bottomRight.x - topLeft.x
|
|
const viewH = bottomRight.y - topLeft.y
|
|
|
|
let minX = Number.POSITIVE_INFINITY
|
|
let minY = Number.POSITIVE_INFINITY
|
|
let maxX = Number.NEGATIVE_INFINITY
|
|
let maxY = Number.NEGATIVE_INFINITY
|
|
for (const n of currentNodes) {
|
|
if (n.x < minX) minX = n.x
|
|
if (n.y < minY) minY = n.y
|
|
if (n.x > maxX) maxX = n.x
|
|
if (n.y > maxY) maxY = n.y
|
|
}
|
|
|
|
const nodeW = maxX - minX || 1
|
|
const nodeH = maxY - minY || 1
|
|
|
|
// Only trigger when the visible area is 3x larger than the node extent
|
|
// (i.e. user has zoomed out significantly past the data)
|
|
const zoomedOut = viewW > nodeW * 3 || viewH > nodeH * 3
|
|
|
|
if (zoomedOut && !loadMoreTimerRef.current) {
|
|
loadMoreTimerRef.current = setTimeout(() => {
|
|
loadMoreTimerRef.current = null
|
|
loadMoreRef.current.onLoadMore?.()
|
|
}, 500)
|
|
}
|
|
},
|
|
[nodes, containerSize.width, containerSize.height],
|
|
)
|
|
|
|
// Navigation
|
|
const handleAutoFit = useCallback(() => {
|
|
if (nodes.length === 0 || !viewportRef.current) return
|
|
stopFollowing()
|
|
viewportRef.current.setMinZoomForNodes(
|
|
nodes,
|
|
containerSize.width,
|
|
graphFitHeight,
|
|
)
|
|
viewportRef.current.fitToNodes(nodes, containerSize.width, graphFitHeight)
|
|
}, [nodes, containerSize.width, graphFitHeight, stopFollowing])
|
|
|
|
const handleCenter = useCallback(() => {
|
|
if (nodes.length === 0 || !viewportRef.current) return
|
|
stopFollowing()
|
|
let sx = 0
|
|
let sy = 0
|
|
for (const n of nodes) {
|
|
sx += n.x
|
|
sy += n.y
|
|
}
|
|
viewportRef.current.centerOn(
|
|
sx / nodes.length,
|
|
sy / nodes.length,
|
|
containerSize.width,
|
|
graphFitHeight,
|
|
)
|
|
}, [nodes, containerSize.width, graphFitHeight, stopFollowing])
|
|
|
|
const handleZoomIn = useCallback(() => {
|
|
const vp = viewportRef.current
|
|
if (!vp) return
|
|
stopFollowing()
|
|
vp.zoomTo(vp.zoom * 1.3, containerSize.width / 2, graphFitHeight / 2)
|
|
}, [containerSize.width, graphFitHeight, stopFollowing])
|
|
|
|
const handleZoomOut = useCallback(() => {
|
|
const vp = viewportRef.current
|
|
if (!vp) return
|
|
stopFollowing()
|
|
vp.zoomTo(vp.zoom / 1.3, containerSize.width / 2, graphFitHeight / 2)
|
|
}, [containerSize.width, graphFitHeight, stopFollowing])
|
|
|
|
const activeDocumentId = useMemo(
|
|
() =>
|
|
openDocumentOnEnter
|
|
? documentIdForNode(
|
|
nodes.find((node) => node.id === (selectedNode ?? hoveredNode)) ??
|
|
null,
|
|
documents,
|
|
)
|
|
: null,
|
|
[openDocumentOnEnter, selectedNode, hoveredNode, nodes, documents],
|
|
)
|
|
const onOpenDocument = useMemo(
|
|
() =>
|
|
!openDocumentOnEnter
|
|
? externalOnOpenDocument
|
|
: externalOnOpenDocument && activeDocumentId
|
|
? (_documentId: string) => externalOnOpenDocument(activeDocumentId)
|
|
: undefined,
|
|
[openDocumentOnEnter, externalOnOpenDocument, activeDocumentId],
|
|
)
|
|
const selectedDocumentId = useMemo(
|
|
() =>
|
|
openDocumentOnEnter
|
|
? documentIdForNode(
|
|
nodes.find((node) => node.id === selectedNode) ?? null,
|
|
documents,
|
|
)
|
|
: null,
|
|
[openDocumentOnEnter, selectedNode, nodes, documents],
|
|
)
|
|
const canOpenOnEnter = openDocumentOnEnter && !!externalOnOpenDocument
|
|
|
|
const handleOpenDocument = useCallback(
|
|
(documentId: string) => {
|
|
setSelectedNode(null)
|
|
setHoveredNode(null)
|
|
onOpenDocument?.(documentId)
|
|
},
|
|
[onOpenDocument],
|
|
)
|
|
|
|
const documentNavigationProps = canOpenOnEnter
|
|
? {
|
|
tabIndex: 0,
|
|
role: "region" as const,
|
|
"aria-label":
|
|
"Memory graph. Select a node, then press Enter to open its document.",
|
|
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
if (
|
|
!selectedDocumentId ||
|
|
event.key !== "Enter" ||
|
|
event.defaultPrevented ||
|
|
event.repeat ||
|
|
event.nativeEvent.isComposing ||
|
|
event.ctrlKey ||
|
|
event.metaKey ||
|
|
event.altKey ||
|
|
event.shiftKey
|
|
)
|
|
return
|
|
if (
|
|
event.target !== event.currentTarget &&
|
|
!(event.target instanceof HTMLCanvasElement)
|
|
)
|
|
return
|
|
event.preventDefault()
|
|
event.stopPropagation()
|
|
handleOpenDocument(selectedDocumentId)
|
|
},
|
|
}
|
|
: {}
|
|
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
const target = e.target as HTMLElement
|
|
if (
|
|
openDocumentOnEnter &&
|
|
(e.defaultPrevented ||
|
|
e.isComposing ||
|
|
e.ctrlKey ||
|
|
e.metaKey ||
|
|
e.altKey ||
|
|
(target instanceof Element &&
|
|
target.closest(
|
|
"input, textarea, select, [contenteditable], [role=textbox]",
|
|
)))
|
|
)
|
|
return
|
|
if (
|
|
target.tagName === "INPUT" ||
|
|
target.tagName === "TEXTAREA" ||
|
|
target.isContentEditable
|
|
)
|
|
return
|
|
|
|
switch (e.key) {
|
|
case "z":
|
|
case "Z":
|
|
handleAutoFit()
|
|
break
|
|
case "c":
|
|
case "C":
|
|
handleCenter()
|
|
break
|
|
case "=":
|
|
case "+":
|
|
handleZoomIn()
|
|
break
|
|
case "-":
|
|
case "_":
|
|
handleZoomOut()
|
|
break
|
|
case "Escape":
|
|
setSelectedNode(null)
|
|
break
|
|
}
|
|
}
|
|
|
|
window.addEventListener("keydown", handler)
|
|
return () => window.removeEventListener("keydown", handler)
|
|
}, [
|
|
handleAutoFit,
|
|
handleCenter,
|
|
handleZoomIn,
|
|
handleZoomOut,
|
|
openDocumentOnEnter,
|
|
])
|
|
|
|
// Arrow key navigation through nodes
|
|
const selectAndCenter = useCallback(
|
|
(nodeId: string) => {
|
|
stopFollowing()
|
|
setSelectedNode(nodeId)
|
|
const n = nodes.find((nd) => nd.id === nodeId)
|
|
if (n && viewportRef.current)
|
|
viewportRef.current.centerOn(
|
|
n.x,
|
|
n.y,
|
|
containerSize.width,
|
|
graphFitHeight,
|
|
)
|
|
},
|
|
[nodes, containerSize.width, graphFitHeight, stopFollowing],
|
|
)
|
|
|
|
const navigateUp = useCallback(() => {
|
|
if (!selectedNode) return
|
|
const chain = chainIndex.current.getChain(selectedNode)
|
|
if (chain && chain.length > 1) {
|
|
const idx = chain.findIndex((e) => e.id === selectedNode)
|
|
const prev = chain[idx - 1]
|
|
if (idx > 0 && prev) {
|
|
selectAndCenter(prev.id)
|
|
return
|
|
}
|
|
}
|
|
// At top of chain or no chain — go to parent document
|
|
const node = nodes.find((n) => n.id === selectedNode)
|
|
if (node?.type === "memory" && "documentId" in node.data) {
|
|
selectAndCenter(node.data.documentId)
|
|
}
|
|
}, [selectedNode, nodes, selectAndCenter])
|
|
|
|
const navigateDown = useCallback(() => {
|
|
if (!selectedNode) return
|
|
// Version chain navigation
|
|
const chain = chainIndex.current.getChain(selectedNode)
|
|
if (chain && chain.length > 1) {
|
|
const idx = chain.findIndex((e) => e.id === selectedNode)
|
|
const next = chain[idx + 1]
|
|
if (idx >= 0 && idx < chain.length - 1 && next) {
|
|
selectAndCenter(next.id)
|
|
return
|
|
}
|
|
}
|
|
// On a document — go to its first memory
|
|
const node = nodes.find((n) => n.id === selectedNode)
|
|
if (node?.type === "document") {
|
|
const child = nodes.find(
|
|
(n) =>
|
|
n.type === "memory" &&
|
|
"documentId" in n.data &&
|
|
n.data.documentId === selectedNode,
|
|
)
|
|
if (child) selectAndCenter(child.id)
|
|
}
|
|
}, [selectedNode, nodes, selectAndCenter])
|
|
|
|
const navigateNext = useCallback(() => {
|
|
if (!selectedNode) return
|
|
const node = nodes.find((n) => n.id === selectedNode)
|
|
if (!node) return
|
|
|
|
if (node.type === "document") {
|
|
const docs = nodes.filter((n) => n.type === "document")
|
|
const idx = docs.findIndex((n) => n.id === selectedNode)
|
|
const next = docs[(idx + 1) % docs.length]
|
|
if (next) selectAndCenter(next.id)
|
|
} else {
|
|
const docId = "documentId" in node.data ? node.data.documentId : null
|
|
const siblings = nodes.filter(
|
|
(n) =>
|
|
n.type === "memory" &&
|
|
"documentId" in n.data &&
|
|
n.data.documentId === docId,
|
|
)
|
|
if (siblings.length === 0) return
|
|
const idx = siblings.findIndex((n) => n.id === selectedNode)
|
|
const next = siblings[(idx + 1) % siblings.length]
|
|
if (next) selectAndCenter(next.id)
|
|
}
|
|
}, [selectedNode, nodes, selectAndCenter])
|
|
|
|
const navigatePrev = useCallback(() => {
|
|
if (!selectedNode) return
|
|
const node = nodes.find((n) => n.id === selectedNode)
|
|
if (!node) return
|
|
|
|
if (node.type === "document") {
|
|
const docs = nodes.filter((n) => n.type === "document")
|
|
const idx = docs.findIndex((n) => n.id === selectedNode)
|
|
const prev = docs[(idx - 1 + docs.length) % docs.length]
|
|
if (prev) selectAndCenter(prev.id)
|
|
} else {
|
|
const docId = "documentId" in node.data ? node.data.documentId : null
|
|
const siblings = nodes.filter(
|
|
(n) =>
|
|
n.type === "memory" &&
|
|
"documentId" in n.data &&
|
|
n.data.documentId === docId,
|
|
)
|
|
if (siblings.length === 0) return
|
|
const idx = siblings.findIndex((n) => n.id === selectedNode)
|
|
const prev = siblings[(idx - 1 + siblings.length) % siblings.length]
|
|
if (prev) selectAndCenter(prev.id)
|
|
}
|
|
}, [selectedNode, nodes, selectAndCenter])
|
|
|
|
// Arrow key navigation
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
const target = e.target as HTMLElement
|
|
if (
|
|
openDocumentOnEnter &&
|
|
(e.defaultPrevented ||
|
|
e.isComposing ||
|
|
e.ctrlKey ||
|
|
e.metaKey ||
|
|
e.altKey ||
|
|
e.shiftKey ||
|
|
(target instanceof Element &&
|
|
target.closest(
|
|
"input, textarea, select, [contenteditable], [role=textbox]",
|
|
)))
|
|
)
|
|
return
|
|
if (
|
|
target.tagName === "INPUT" ||
|
|
target.tagName === "TEXTAREA" ||
|
|
target.isContentEditable
|
|
)
|
|
return
|
|
|
|
switch (e.key) {
|
|
case "ArrowUp":
|
|
e.preventDefault()
|
|
navigateUp()
|
|
break
|
|
case "ArrowDown":
|
|
e.preventDefault()
|
|
navigateDown()
|
|
break
|
|
case "ArrowRight":
|
|
e.preventDefault()
|
|
navigateNext()
|
|
break
|
|
case "ArrowLeft":
|
|
e.preventDefault()
|
|
navigatePrev()
|
|
break
|
|
}
|
|
}
|
|
|
|
window.addEventListener("keydown", handler)
|
|
return () => window.removeEventListener("keydown", handler)
|
|
}, [
|
|
navigateUp,
|
|
navigateDown,
|
|
navigateNext,
|
|
navigatePrev,
|
|
openDocumentOnEnter,
|
|
])
|
|
|
|
// Slideshow — use refs to avoid re-creating the interval on resize or node updates
|
|
const nodesRef = useRef(nodes)
|
|
nodesRef.current = nodes
|
|
const containerSizeRef = useRef(containerSize)
|
|
containerSizeRef.current = containerSize
|
|
const onSlideshowNodeChangeRef = useRef(onSlideshowNodeChange)
|
|
onSlideshowNodeChangeRef.current = onSlideshowNodeChange
|
|
|
|
useEffect(() => {
|
|
if (!isSlideshowActive || nodes.length === 0) {
|
|
if (!isSlideshowActive) {
|
|
setSelectedNode(null)
|
|
}
|
|
return
|
|
}
|
|
|
|
let lastIdx = -1
|
|
let coolDownTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
const pick = () => {
|
|
const currentNodes = nodesRef.current
|
|
if (currentNodes.length === 0) return
|
|
let idx: number
|
|
if (currentNodes.length > 1) {
|
|
do {
|
|
idx = Math.floor(Math.random() * currentNodes.length)
|
|
} while (idx === lastIdx)
|
|
} else {
|
|
idx = 0
|
|
}
|
|
lastIdx = idx
|
|
const n = currentNodes[idx]
|
|
if (!n) return
|
|
setSelectedNode(n.id)
|
|
const sz = containerSizeRef.current
|
|
viewportRef.current?.centerOn(n.x, n.y, sz.width, sz.height)
|
|
simulationRef.current?.reheat()
|
|
onSlideshowNodeChangeRef.current?.(n.id)
|
|
// Clear any pending coolDown before scheduling a new one
|
|
if (coolDownTimer) clearTimeout(coolDownTimer)
|
|
coolDownTimer = setTimeout(() => {
|
|
simulationRef.current?.coolDown()
|
|
coolDownTimer = null
|
|
}, 1000)
|
|
}
|
|
|
|
pick()
|
|
const interval = setInterval(pick, 3500)
|
|
return () => {
|
|
clearInterval(interval)
|
|
if (coolDownTimer) clearTimeout(coolDownTimer)
|
|
simulationRef.current?.coolDown()
|
|
}
|
|
}, [isSlideshowActive, nodes.length])
|
|
|
|
// Active node: selected takes priority, then hovered
|
|
const activeNodeId = selectedNode ?? hoveredNode
|
|
const activeNodeData = useMemo(() => {
|
|
if (!activeNodeId) return null
|
|
return nodes.find((n) => n.id === activeNodeId) ?? null
|
|
}, [activeNodeId, nodes])
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: viewportVersion intentionally used as proxy for viewport state changes
|
|
const activePopoverPosition = useMemo(() => {
|
|
if (!activeNodeData || !viewportRef.current) return null
|
|
const vp = viewportRef.current
|
|
const screen = vp.worldToScreen(activeNodeData.x, activeNodeData.y)
|
|
return {
|
|
screenX: screen.x,
|
|
screenY: screen.y,
|
|
nodeRadius: (activeNodeData.size * vp.zoom) / 2,
|
|
}
|
|
// viewportVersion triggers re-computation on any viewport change (pan + zoom)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [activeNodeData, viewportVersion])
|
|
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: limitedDocuments triggers re-computation after chainIndex.current.rebuild() runs with new data
|
|
const activeVersionChain = useMemo(() => {
|
|
if (!activeNodeData || activeNodeData.type !== "memory") return null
|
|
return chainIndex.current.getChain(activeNodeData.id)
|
|
}, [activeNodeData, limitedDocuments])
|
|
|
|
const isLayoutPending = !hasContainerSize && limitedDocuments.length > 0
|
|
const isLoading = externalIsLoading || isLayoutPending
|
|
|
|
if (externalError) {
|
|
const errorContainerStyle: React.CSSProperties = {
|
|
height: "100%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
backgroundColor: variant === "console" ? colors.bg : "transparent",
|
|
borderRadius: 12,
|
|
}
|
|
|
|
const errorBoxStyle: React.CSSProperties = {
|
|
color: colors.textSecondary,
|
|
paddingLeft: 24,
|
|
paddingRight: 24,
|
|
paddingTop: 16,
|
|
paddingBottom: 16,
|
|
}
|
|
|
|
return (
|
|
<div style={errorContainerStyle}>
|
|
<div style={errorBoxStyle}>
|
|
Error loading graph: {externalError.message}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const wrapperStyle: React.CSSProperties = {
|
|
position: "relative",
|
|
height: "100%",
|
|
borderRadius: 12,
|
|
overflow: "hidden",
|
|
backgroundColor: variant === "console" ? colors.bg : "transparent",
|
|
backgroundImage:
|
|
variant === "console"
|
|
? `radial-gradient(circle, ${colors.dotColor ?? colors.textMuted} 0.5px, transparent 0.5px)`
|
|
: undefined,
|
|
backgroundSize: variant === "console" ? "16px 16px" : undefined,
|
|
}
|
|
|
|
const canvasContainerStyle: React.CSSProperties = {
|
|
width: "100%",
|
|
height: "100%",
|
|
position: "relative",
|
|
overflow: "hidden",
|
|
touchAction: "none",
|
|
userSelect: "none",
|
|
}
|
|
|
|
const emptyStateStyle: React.CSSProperties = {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}
|
|
|
|
const bottomLeftStackStyle: React.CSSProperties = {
|
|
position: "absolute",
|
|
bottom: isCompactViewport ? 12 : 16,
|
|
left: isCompactViewport ? 12 : 16,
|
|
zIndex: 20,
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
alignItems: "flex-start",
|
|
gap: 8,
|
|
maxWidth: isCompactViewport ? "calc(100% - 24px)" : undefined,
|
|
}
|
|
|
|
return (
|
|
<div style={wrapperStyle}>
|
|
<LoadingIndicator
|
|
isLoading={isLoading}
|
|
isLoadingMore={isLoadingMore}
|
|
labels={resolvedLabels}
|
|
totalLoaded={totalCount ?? documents.length}
|
|
colors={colors}
|
|
/>
|
|
|
|
{!isLoading && !nodes.some((n) => n.type === "document") && children && (
|
|
<div style={emptyStateStyle}>{children}</div>
|
|
)}
|
|
|
|
<div
|
|
style={canvasContainerStyle}
|
|
ref={containerRef}
|
|
{...documentNavigationProps}
|
|
onPointerDownCapture={(event) => {
|
|
if (event.target instanceof HTMLCanvasElement) {
|
|
stopFollowing()
|
|
if (canOpenOnEnter)
|
|
containerRef.current?.focus({ preventScroll: true })
|
|
}
|
|
}}
|
|
onWheelCapture={(event) => {
|
|
if (event.target instanceof HTMLCanvasElement) stopFollowing()
|
|
}}
|
|
>
|
|
{hasContainerSize && (
|
|
<GraphCanvas
|
|
appearance={appearance}
|
|
colors={colors}
|
|
edges={edges}
|
|
height={containerSize.height}
|
|
highlightDocumentIds={highlightsVisible ? highlightDocumentIds : []}
|
|
nodes={nodes}
|
|
onNodeClick={handleNodeClick}
|
|
onNodeDragEnd={handleNodeDragEnd}
|
|
onNodeDragStart={handleNodeDragStart}
|
|
onNodeHover={handleNodeHover}
|
|
onViewportChange={handleViewportChange}
|
|
selectedNodeId={selectedNode}
|
|
simulation={simulation ?? undefined}
|
|
viewportRef={viewportRef}
|
|
width={containerSize.width}
|
|
canvasRef={externalCanvasRef}
|
|
showFps={showFps}
|
|
variant={variant}
|
|
/>
|
|
)}
|
|
|
|
{activeNodeData && activePopoverPosition && (
|
|
<NodeHoverPopover
|
|
colors={colors}
|
|
containerBounds={containerBounds ?? undefined}
|
|
labels={resolvedLabels}
|
|
node={activeNodeData}
|
|
zIndex={hoverPopoverZIndex}
|
|
nodeRadius={activePopoverPosition.nodeRadius}
|
|
onNavigateDown={navigateDown}
|
|
onNavigateNext={navigateNext}
|
|
onNavigatePrev={navigatePrev}
|
|
onNavigateUp={navigateUp}
|
|
onSelectNode={handleNodeClick}
|
|
onOpenDocument={onOpenDocument ? handleOpenDocument : undefined}
|
|
openDocumentShortcut={
|
|
canOpenOnEnter && selectedDocumentId ? "Enter" : undefined
|
|
}
|
|
showDocumentTitle={appearance?.documentNodes === "stacked"}
|
|
screenX={activePopoverPosition.screenX}
|
|
screenY={activePopoverPosition.screenY}
|
|
versionChain={activeVersionChain}
|
|
/>
|
|
)}
|
|
|
|
{containerSize.width > 0 && (
|
|
<div style={bottomLeftStackStyle}>
|
|
<NavigationControls
|
|
nodes={nodes}
|
|
compact={isCompactViewport}
|
|
onAutoFit={handleAutoFit}
|
|
onCenter={handleCenter}
|
|
onZoomIn={handleZoomIn}
|
|
onZoomOut={handleZoomOut}
|
|
zoomLevel={zoomDisplay}
|
|
colors={colors}
|
|
/>
|
|
<Legend
|
|
colors={colors}
|
|
edges={edges}
|
|
labels={resolvedLabels}
|
|
hoveredNode={hoveredNode}
|
|
compact={isCompactViewport}
|
|
isLoading={isLoading}
|
|
maxHeight={compactLegendMaxHeight}
|
|
nodes={nodes}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{showMinimap &&
|
|
!isCompactViewport &&
|
|
containerSize.width > 0 &&
|
|
nodes.length > 0 && (
|
|
<div
|
|
aria-hidden="true"
|
|
style={{
|
|
position: "absolute",
|
|
bottom: 16,
|
|
right: 16,
|
|
zIndex: 20,
|
|
}}
|
|
>
|
|
<Minimap
|
|
nodes={nodes}
|
|
colors={colors}
|
|
viewportRef={viewportRef}
|
|
canvasWidth={containerSize.width}
|
|
canvasHeight={containerSize.height}
|
|
simulation={simulation}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|