mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-12 03:48:08 +00:00
feat(memory-graph): add opt-in stacked nodes and emphasized connections (#1792)
Some checks are pending
Publish Memory Graph / publish (push) Waiting to run
Some checks are pending
Publish Memory Graph / publish (push) Waiting to run
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
parent
709723dea6
commit
a5e84e0676
12 changed files with 422 additions and 24 deletions
|
|
@ -59,6 +59,23 @@ export default function GraphPage() {
|
|||
}
|
||||
```
|
||||
|
||||
## Optional rendering and document navigation
|
||||
|
||||
Existing icon-based document nodes and connection styling remain the defaults. Opt in to stacked squares and stronger semantic connections with `appearance`:
|
||||
|
||||
```tsx
|
||||
<MemoryGraph
|
||||
documents={documents}
|
||||
appearance={{ documentNodes: 'stacked', connections: 'emphasized' }}
|
||||
openDocumentOnEnter
|
||||
onOpenDocument={openPersistedDocument}
|
||||
/>
|
||||
```
|
||||
|
||||
`documentNodes` accepts `'icons'` or `'stacked'`; `connections` accepts `'default'` or `'emphasized'`. Both fields are optional and also work on `GraphCanvas`. Stacks stay inside existing node bounds and simplify at low zoom. Use `colors.edgeDocument` to configure document-source ink; older custom `GraphCanvas` palettes without that field use `textSecondary` in emphasized mode.
|
||||
|
||||
`openDocumentOnEnter` is off by default. With an `onOpenDocument` callback, `MemoryGraph` exposes a focused Enter action for the selected persisted document or one confirmed memory source. Typing, modifier shortcuts, repeats, composition and keys outside the graph do not open documents. Your callback retains responsibility for routing and access control; consumers without a callback receive no new opening behavior.
|
||||
|
||||
## Backend API route
|
||||
|
||||
Create an API route to fetch documents from Supermemory:
|
||||
|
|
|
|||
2
bun.lock
2
bun.lock
|
|
@ -204,7 +204,7 @@
|
|||
},
|
||||
"packages/memory-graph": {
|
||||
"name": "@supermemory/memory-graph",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"dependencies": {
|
||||
"d3-force": "^3.0.0",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -69,6 +69,25 @@ function App() {
|
|||
|
||||
Console mode uses the supplied theme colors for its surface, 16px dot grid, document icons, and node fills and strokes. Consumer mode keeps its transparent surface and cluster colors.
|
||||
|
||||
### Opt-in appearance and document opening
|
||||
|
||||
The default appearance remains the icon-based document nodes and existing connection styling. Configure the same options on `MemoryGraph` or `GraphCanvas` to use stacked document squares and stronger connections:
|
||||
|
||||
```tsx
|
||||
<MemoryGraph
|
||||
documents={documents}
|
||||
appearance={{ documentNodes: "stacked", connections: "emphasized" }}
|
||||
openDocumentOnEnter
|
||||
onOpenDocument={openPersistedDocument}
|
||||
/>
|
||||
```
|
||||
|
||||
`appearance.documentNodes` accepts `"icons"` (default) or `"stacked"`. Stacks have no icons or text, stay inside the original node bounds and collapse to plain blocks at smaller zooms. Selection uses a single accented boundary; full document identity stays in the popover.
|
||||
|
||||
`appearance.connections` accepts `"default"` (default) or `"emphasized"`. Emphasized connections recover smoothly from a quiet overview, with stronger semantic relations and subordinate, glow-free document sources. Configure `colors.edgeDocument` for the source ink. Older custom `GraphCanvas` palettes without that field use `textSecondary` for emphasized sources. No node positions, hit targets or stored graph data change.
|
||||
|
||||
`openDocumentOnEnter` defaults to `false` and applies only to `MemoryGraph`. With an `onOpenDocument` callback, it adds a discoverable, focus-scoped Enter action. Opening uses a loaded persisted document ID; memories require one confirmed source. Typing, modifiers, repeated keys and composition do not open a document. Native buttons retain their normal activation. The package calls your callback and does not own routing or authorization. Without the option or callback, no new focus target or Enter behavior is added.
|
||||
|
||||
Set `colors.dotColor` or the `--graph-dot` CSS variable to style the dot grid independently of text. When neither is set, the grid uses `textMuted`.
|
||||
|
||||
Document-to-memory links use `colors.edgeDocument` or `--graph-edge-document`, while derived-memory relations use `colors.edgeDerives` or `--graph-edge-derives`.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@supermemory/memory-graph",
|
||||
"version": "0.2.4",
|
||||
"version": "0.2.5",
|
||||
"description": "Interactive graph visualization component for Supermemory - visualize and explore your memory connections",
|
||||
"type": "module",
|
||||
"main": "./src/index.tsx",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
GraphEdge,
|
||||
GraphNode,
|
||||
GraphThemeColors,
|
||||
GraphAppearance,
|
||||
MemoryNodeData,
|
||||
} from "../types"
|
||||
import type { ViewportState } from "./viewport"
|
||||
|
|
@ -14,6 +15,7 @@ export interface RenderState {
|
|||
hoveredNodeId: string | null
|
||||
highlightIds: Set<string>
|
||||
dimProgress: number
|
||||
appearance?: GraphAppearance
|
||||
}
|
||||
|
||||
// Module-level reusable batch map – cleared each frame instead of reallocating
|
||||
|
|
@ -23,6 +25,8 @@ const RELATION_LOD_MAX_BACKGROUND_EDGES = 260
|
|||
const RELATION_LOD_DENSE_COUNT = 180
|
||||
const DOCUMENT_LOD_ZOOM = 0.38
|
||||
const DOCUMENT_LOD_MAX_BACKGROUND_EDGES = 3200
|
||||
const EDGE_OVERVIEW_ZOOM = 0.1
|
||||
const EDGE_DETAIL_ZOOM = 0.35
|
||||
const DENSE_POINT_THRESHOLD = 25000
|
||||
const DENSE_POINT_ZOOM = 0.42
|
||||
|
||||
|
|
@ -82,9 +86,21 @@ export function renderFrame(
|
|||
function edgeStyle(
|
||||
edge: GraphEdge,
|
||||
colors: GraphThemeColors,
|
||||
emphasized = false,
|
||||
): { color: string; width: number; opacity: number } {
|
||||
if (emphasized && edge.edgeType !== "document") {
|
||||
if (edge.edgeType === "derives")
|
||||
return { color: colors.edgeDerives, width: 2, opacity: 0.9 }
|
||||
if (edge.edgeType === "updates")
|
||||
return { color: colors.edgeUpdates, width: 2.2, opacity: 0.94 }
|
||||
return { color: colors.edgeExtends, width: 2, opacity: 0.88 }
|
||||
}
|
||||
if (edge.edgeType === "document")
|
||||
return { color: colors.edgeDocument, width: 1.2, opacity: 0.4 }
|
||||
return {
|
||||
color: colors.edgeDocument ?? colors.edgeDerives,
|
||||
width: 1.2,
|
||||
opacity: 0.4,
|
||||
}
|
||||
if (edge.edgeType === "derives")
|
||||
return { color: colors.edgeDerives, width: 1.2, opacity: 0.4 }
|
||||
if (edge.edgeType === "updates")
|
||||
|
|
@ -139,7 +155,40 @@ function applyRelationLevelOfDetail(
|
|||
zoom: number,
|
||||
hasFocus: boolean,
|
||||
hasActiveHover: boolean,
|
||||
emphasized = false,
|
||||
) {
|
||||
if (emphasized) {
|
||||
if (edgeType === "document") return { style, glow: false }
|
||||
if (hasFocus || hasActiveHover) {
|
||||
return {
|
||||
style:
|
||||
edgeType === "updates"
|
||||
? {
|
||||
...style,
|
||||
width: Math.max(style.width, hasActiveHover ? 3.2 : 2.8),
|
||||
opacity: Math.max(style.opacity, hasActiveHover ? 1 : 0.98),
|
||||
}
|
||||
: style,
|
||||
glow: edgeType !== "extends",
|
||||
}
|
||||
}
|
||||
const detail = edgeDetailProgress(zoom)
|
||||
const overviewOpacity =
|
||||
edgeType === "updates" ? 0.039 : edgeType === "derives" ? 0.044 : 0.03
|
||||
return {
|
||||
style: {
|
||||
...style,
|
||||
width: 0.45 + (style.width - 0.45) * detail,
|
||||
opacity: overviewOpacity + (style.opacity - overviewOpacity) * detail,
|
||||
},
|
||||
glow:
|
||||
edgeType === "derives"
|
||||
? zoom >= DOCUMENT_LOD_ZOOM
|
||||
: edgeType === "updates" &&
|
||||
(zoom >= RELATION_LOD_ZOOM ||
|
||||
(relationEdgeCount <= RELATION_LOD_DENSE_COUNT && detail === 1)),
|
||||
}
|
||||
}
|
||||
if (edgeType === "document") return { style, glow: true }
|
||||
if (hasFocus || hasActiveHover) {
|
||||
const isUpdate = edgeType === "updates"
|
||||
|
|
@ -181,6 +230,27 @@ function applyRelationLevelOfDetail(
|
|||
}
|
||||
}
|
||||
|
||||
function edgeDetailProgress(zoom: number): number {
|
||||
const progress = clampNumber(
|
||||
(zoom - EDGE_OVERVIEW_ZOOM) / (EDGE_DETAIL_ZOOM - EDGE_OVERVIEW_ZOOM),
|
||||
0,
|
||||
1,
|
||||
)
|
||||
return progress * progress * (3 - 2 * progress)
|
||||
}
|
||||
|
||||
function isDocumentSourceEdge(
|
||||
edge: GraphEdge,
|
||||
nodeMap: Map<string, GraphNode>,
|
||||
): boolean {
|
||||
if (edge.edgeType === "document") return true
|
||||
const source =
|
||||
typeof edge.source === "string" ? nodeMap.get(edge.source) : edge.source
|
||||
const target =
|
||||
typeof edge.target === "string" ? nodeMap.get(edge.target) : edge.target
|
||||
return source?.type === "document" || target?.type === "document"
|
||||
}
|
||||
|
||||
function clampNumber(value: number, min: number, max: number): number {
|
||||
return value < min ? min : value > max ? max : value
|
||||
}
|
||||
|
|
@ -217,8 +287,17 @@ function drawEdges(
|
|||
): void {
|
||||
const margin = 100
|
||||
const hasDim = state.selectedNodeId !== null && state.dimProgress > 0
|
||||
const emphasized = state.appearance?.connections === "emphasized"
|
||||
const relationEdgeCount = edges.reduce(
|
||||
(count, edge) => count + (edge.edgeType === "document" ? 0 : 1),
|
||||
(count, edge) =>
|
||||
count +
|
||||
((
|
||||
emphasized
|
||||
? isDocumentSourceEdge(edge, nodeMap)
|
||||
: edge.edgeType === "document"
|
||||
)
|
||||
? 0
|
||||
: 1),
|
||||
0,
|
||||
)
|
||||
const documentEdgeCount = edges.length - relationEdgeCount
|
||||
|
|
@ -228,7 +307,10 @@ function drawEdges(
|
|||
const prepared: PreparedEdge[] = []
|
||||
|
||||
for (const edge of edges) {
|
||||
const edgeType = edge.edgeType ?? "document"
|
||||
const edgeType =
|
||||
emphasized && isDocumentSourceEdge(edge, nodeMap)
|
||||
? "document"
|
||||
: (edge.edgeType ?? "document")
|
||||
const srcId = typeof edge.source === "string" ? edge.source : edge.source.id
|
||||
const tgtId = typeof edge.target === "string" ? edge.target : edge.target.id
|
||||
const hoverConnected =
|
||||
|
|
@ -293,16 +375,29 @@ function drawEdges(
|
|||
}
|
||||
|
||||
const edgeDetail = applyRelationLevelOfDetail(
|
||||
edgeStyle(edge, colors),
|
||||
edgeStyle(edge, colors, emphasized),
|
||||
edgeType,
|
||||
relationEdgeCount,
|
||||
viewport.zoom,
|
||||
hasDim && connected,
|
||||
edgeType === "updates" && hoverConnected,
|
||||
emphasized,
|
||||
)
|
||||
let style = edgeDetail.style
|
||||
let glow = edgeDetail.glow
|
||||
if (edgeType === "document" && documentStride > 1 && !activeConnected) {
|
||||
if (emphasized && edgeType === "document") {
|
||||
const detail = edgeDetailProgress(viewport.zoom)
|
||||
style = {
|
||||
color: colors.edgeDocument ?? colors.textSecondary,
|
||||
width: activeConnected ? 1.8 : 0.35 + 0.85 * detail,
|
||||
opacity: activeConnected ? 0.65 : 0.0112 + (0.45 - 0.0112) * detail,
|
||||
}
|
||||
glow = false
|
||||
} else if (
|
||||
edgeType === "document" &&
|
||||
documentStride > 1 &&
|
||||
!activeConnected
|
||||
) {
|
||||
const zoomFactor = clampNumber(
|
||||
viewport.zoom / DOCUMENT_LOD_ZOOM,
|
||||
0.08,
|
||||
|
|
@ -534,6 +629,7 @@ function drawNodes(
|
|||
isHovered,
|
||||
isHighlighted,
|
||||
colors,
|
||||
state.appearance?.documentNodes,
|
||||
)
|
||||
} else {
|
||||
drawMemoryNode(
|
||||
|
|
@ -549,7 +645,11 @@ function drawNodes(
|
|||
)
|
||||
}
|
||||
|
||||
if (isSelected || isHighlighted || isHovered) {
|
||||
if (
|
||||
(node.type === "memory" ||
|
||||
state.appearance?.documentNodes !== "stacked") &&
|
||||
(isSelected || isHighlighted || isHovered)
|
||||
) {
|
||||
drawGlow(
|
||||
ctx,
|
||||
screen.x,
|
||||
|
|
@ -712,12 +812,26 @@ function drawDocumentNode(
|
|||
isHovered: boolean,
|
||||
isHighlighted: boolean,
|
||||
colors: GraphThemeColors,
|
||||
style: GraphAppearance["documentNodes"] = "icons",
|
||||
): void {
|
||||
if (style === "stacked") {
|
||||
drawStackedDocumentNode(
|
||||
ctx,
|
||||
sx,
|
||||
sy,
|
||||
size,
|
||||
node,
|
||||
isSelected,
|
||||
isHovered,
|
||||
isHighlighted,
|
||||
colors,
|
||||
)
|
||||
return
|
||||
}
|
||||
const half = size * 0.5
|
||||
const cornerR = 8 * (size / 50)
|
||||
const clusterColor = node.clusterColor ?? colors.docStroke
|
||||
|
||||
// Drop shadow for selected/hovered nodes
|
||||
if (isSelected || isHovered) {
|
||||
ctx.save()
|
||||
ctx.shadowColor = isSelected ? colors.accent : clusterColor
|
||||
|
|
@ -726,7 +840,6 @@ function drawDocumentNode(
|
|||
ctx.shadowOffsetY = 0
|
||||
}
|
||||
|
||||
// Subtle gradient fill for document nodes
|
||||
const grad = ctx.createLinearGradient(
|
||||
sx - half,
|
||||
sy - half,
|
||||
|
|
@ -784,6 +897,59 @@ function drawDocumentNode(
|
|||
)
|
||||
}
|
||||
|
||||
function drawStackedDocumentNode(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
sx: number,
|
||||
sy: number,
|
||||
size: number,
|
||||
node: GraphNode,
|
||||
isSelected: boolean,
|
||||
isHovered: boolean,
|
||||
isHighlighted: boolean,
|
||||
colors: GraphThemeColors,
|
||||
): void {
|
||||
const half = size * 0.5
|
||||
const cornerR = Math.min(8, Math.max(2, size * 0.12))
|
||||
const stackOffset = Math.min(6, Math.max(0, (size - 24) * 0.1))
|
||||
const layerSize = size - stackOffset
|
||||
const focused = isSelected || isHighlighted
|
||||
const borderColor = node.clusterColor ?? node.borderColor ?? colors.docStroke
|
||||
ctx.save()
|
||||
if (stackOffset > 0) {
|
||||
ctx.fillStyle = focused
|
||||
? mixHexColors(colors.docFill, colors.accent, 0.2)
|
||||
: colors.docFill
|
||||
ctx.strokeStyle = colors.textMuted
|
||||
ctx.lineWidth = 1
|
||||
roundRect(
|
||||
ctx,
|
||||
sx - half + stackOffset,
|
||||
sy - half,
|
||||
layerSize,
|
||||
layerSize,
|
||||
cornerR,
|
||||
)
|
||||
ctx.fill()
|
||||
if (!focused) ctx.stroke()
|
||||
}
|
||||
ctx.fillStyle = focused
|
||||
? mixHexColors(colors.textMuted, colors.accent, 0.28)
|
||||
: colors.textMuted
|
||||
ctx.strokeStyle = focused || isHovered ? colors.accent : borderColor
|
||||
ctx.lineWidth = focused ? 2.25 : isHovered ? 1.5 : 1
|
||||
roundRect(
|
||||
ctx,
|
||||
sx - half,
|
||||
sy - half + stackOffset,
|
||||
layerSize,
|
||||
layerSize,
|
||||
cornerR,
|
||||
)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawMemoryNode(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
sx: number,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
|
|||
width,
|
||||
height,
|
||||
colors,
|
||||
appearance,
|
||||
highlightDocumentIds,
|
||||
selectedNodeId = null,
|
||||
onNodeHover,
|
||||
|
|
@ -62,6 +63,7 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
|
|||
width,
|
||||
height,
|
||||
colors,
|
||||
appearance,
|
||||
selectedNodeId,
|
||||
hoveredNodeId: null as string | null,
|
||||
highlightIds: new Set(highlightDocumentIds ?? []),
|
||||
|
|
@ -75,6 +77,7 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
|
|||
s.current.width = width
|
||||
s.current.height = height
|
||||
s.current.colors = colors
|
||||
s.current.appearance = appearance
|
||||
|
||||
// Stable callback refs so InputHandler never needs recreation
|
||||
const cb = useRef({
|
||||
|
|
@ -128,8 +131,18 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
|
|||
useEffect(() => {
|
||||
s.current.selectedNodeId = selectedNodeId
|
||||
s.current.dimTarget = selectedNodeId ? 1 : 0
|
||||
s.current.colors = colors
|
||||
s.current.appearance = {
|
||||
documentNodes: appearance?.documentNodes,
|
||||
connections: appearance?.connections,
|
||||
}
|
||||
renderNeeded.current = true
|
||||
}, [selectedNodeId])
|
||||
}, [
|
||||
selectedNodeId,
|
||||
colors,
|
||||
appearance?.documentNodes,
|
||||
appearance?.connections,
|
||||
])
|
||||
|
||||
// Create viewport + input handler (once per variant)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: canvasRef and externalViewportRef are refs — mutations do not trigger re-renders, intentionally omitted
|
||||
|
|
@ -278,6 +291,7 @@ export const GraphCanvas = memo<ExtendedGraphCanvasProps>(function GraphCanvas({
|
|||
hoveredNodeId: cur.hoveredNodeId,
|
||||
highlightIds: cur.highlightIds,
|
||||
dimProgress: cur.dimProgress,
|
||||
appearance: cur.appearance,
|
||||
},
|
||||
nodeMapRef.current,
|
||||
cur.colors,
|
||||
|
|
|
|||
|
|
@ -532,7 +532,9 @@ export const Legend = memo(function Legend({
|
|||
<div>
|
||||
<div style={rowStyle}>
|
||||
<div style={rowLeftStyle}>
|
||||
<LineIcon color={colors.edgeDocument} />
|
||||
<LineIcon
|
||||
color={colors.edgeDocument ?? colors.edgeDerives}
|
||||
/>
|
||||
<span style={edgeLabelStyle}>
|
||||
{labels.documentSourceEdge}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { useGraphTheme } from "../hooks/use-graph-theme"
|
|||
import { useInitialGraphFit } from "../hooks/use-initial-graph-fit"
|
||||
import type {
|
||||
GraphApiDocument,
|
||||
GraphNode,
|
||||
MemoryNodeData,
|
||||
GraphThemeColors,
|
||||
MemoryGraphProps,
|
||||
ResolvedMemoryGraphLabels,
|
||||
|
|
@ -28,6 +30,31 @@ import { LoadingIndicator } from "./loading-indicator"
|
|||
import { NavigationControls } from "./navigation-controls"
|
||||
import { NodeHoverPopover } from "./node-hover-popover"
|
||||
|
||||
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,
|
||||
|
|
@ -46,9 +73,11 @@ export function MemoryGraph({
|
|||
canvasRef: externalCanvasRef,
|
||||
colors: colorOverrides,
|
||||
totalCount,
|
||||
onOpenDocument,
|
||||
onOpenDocument: externalOnOpenDocument,
|
||||
labels: labelOverrides,
|
||||
layering,
|
||||
appearance,
|
||||
openDocumentOnEnter = false,
|
||||
}: MemoryGraphProps) {
|
||||
const resolvedLabels = useMemo<ResolvedMemoryGraphLabels>(
|
||||
() => ({ ...DEFAULT_LABELS, ...labelOverrides }),
|
||||
|
|
@ -358,9 +387,38 @@ export function MemoryGraph({
|
|||
vp.zoomTo(vp.zoom / 1.3, containerSize.width / 2, graphFitHeight / 2)
|
||||
}, [containerSize.width, graphFitHeight, stopFollowing])
|
||||
|
||||
// Wrap onOpenDocument to dismiss the popover before opening the modal.
|
||||
// Without this, the popover overlay stays mounted on top of the
|
||||
// document modal, obscuring it and intercepting clicks.
|
||||
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)
|
||||
|
|
@ -370,11 +428,53 @@ export function MemoryGraph({
|
|||
[onOpenDocument],
|
||||
)
|
||||
|
||||
// Keyboard shortcuts — using useEffect with keydown listener
|
||||
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) => {
|
||||
// Don't handle if user is typing in an input
|
||||
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" ||
|
||||
|
|
@ -407,7 +507,13 @@ export function MemoryGraph({
|
|||
|
||||
window.addEventListener("keydown", handler)
|
||||
return () => window.removeEventListener("keydown", handler)
|
||||
}, [handleAutoFit, handleCenter, handleZoomIn, handleZoomOut])
|
||||
}, [
|
||||
handleAutoFit,
|
||||
handleCenter,
|
||||
handleZoomIn,
|
||||
handleZoomOut,
|
||||
openDocumentOnEnter,
|
||||
])
|
||||
|
||||
// Arrow key navigation through nodes
|
||||
const selectAndCenter = useCallback(
|
||||
|
|
@ -523,6 +629,20 @@ export function MemoryGraph({
|
|||
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" ||
|
||||
|
|
@ -552,7 +672,13 @@ export function MemoryGraph({
|
|||
|
||||
window.addEventListener("keydown", handler)
|
||||
return () => window.removeEventListener("keydown", handler)
|
||||
}, [navigateUp, navigateDown, navigateNext, navigatePrev])
|
||||
}, [
|
||||
navigateUp,
|
||||
navigateDown,
|
||||
navigateNext,
|
||||
navigatePrev,
|
||||
openDocumentOnEnter,
|
||||
])
|
||||
|
||||
// Slideshow — use refs to avoid re-creating the interval on resize or node updates
|
||||
const nodesRef = useRef(nodes)
|
||||
|
|
@ -728,8 +854,13 @@ export function MemoryGraph({
|
|||
<div
|
||||
style={canvasContainerStyle}
|
||||
ref={containerRef}
|
||||
{...documentNavigationProps}
|
||||
onPointerDownCapture={(event) => {
|
||||
if (event.target instanceof HTMLCanvasElement) stopFollowing()
|
||||
if (event.target instanceof HTMLCanvasElement) {
|
||||
stopFollowing()
|
||||
if (canOpenOnEnter)
|
||||
containerRef.current?.focus({ preventScroll: true })
|
||||
}
|
||||
}}
|
||||
onWheelCapture={(event) => {
|
||||
if (event.target instanceof HTMLCanvasElement) stopFollowing()
|
||||
|
|
@ -737,6 +868,7 @@ export function MemoryGraph({
|
|||
>
|
||||
{hasContainerSize && (
|
||||
<GraphCanvas
|
||||
appearance={appearance}
|
||||
colors={colors}
|
||||
edges={edges}
|
||||
height={containerSize.height}
|
||||
|
|
@ -771,6 +903,10 @@ export function MemoryGraph({
|
|||
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}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ export interface NodeHoverPopoverProps {
|
|||
onNavigateDown?: () => void
|
||||
onSelectNode?: (nodeId: string) => void
|
||||
onOpenDocument?: (documentId: string) => void
|
||||
openDocumentShortcut?: string
|
||||
showDocumentTitle?: boolean
|
||||
}
|
||||
|
||||
function useCopyToClipboard(timeout = 2000) {
|
||||
|
|
@ -105,11 +107,13 @@ function NavButton({
|
|||
label,
|
||||
onClick,
|
||||
colors,
|
||||
shortcut,
|
||||
}: {
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
onClick?: () => void
|
||||
colors: GraphThemeColors
|
||||
shortcut?: string
|
||||
}) {
|
||||
const buttonStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
|
|
@ -130,6 +134,8 @@ function NavButton({
|
|||
|
||||
return (
|
||||
<button
|
||||
aria-label={shortcut ? label : undefined}
|
||||
aria-keyshortcuts={shortcut}
|
||||
onClick={onClick}
|
||||
style={buttonStyle}
|
||||
type="button"
|
||||
|
|
@ -142,6 +148,20 @@ function NavButton({
|
|||
>
|
||||
<KeyBadge colors={colors}>{icon}</KeyBadge>
|
||||
<span style={labelStyle}>{label}</span>
|
||||
{shortcut && (
|
||||
<kbd
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: colors.popoverTextMuted,
|
||||
border: `1px solid ${colors.controlBorder}`,
|
||||
borderRadius: 4,
|
||||
padding: "2px 4px",
|
||||
backgroundColor: colors.controlBg,
|
||||
}}
|
||||
>
|
||||
{shortcut}
|
||||
</kbd>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -322,6 +342,8 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
onNavigateDown,
|
||||
onSelectNode,
|
||||
onOpenDocument,
|
||||
openDocumentShortcut,
|
||||
showDocumentTitle = false,
|
||||
}) {
|
||||
const CARD_W = 280
|
||||
const SHORTCUTS_W = 160
|
||||
|
|
@ -532,6 +554,18 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
/>
|
||||
) : (
|
||||
<div style={contentPadStyle}>
|
||||
{showDocumentTitle && docData && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: colors.popoverTextPrimary,
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
{docData.title || "Untitled document"}
|
||||
</div>
|
||||
)}
|
||||
<p style={contentTextStyle}>{content || "No content"}</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -632,7 +666,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
<CopyableId
|
||||
colors={colors}
|
||||
label={labels.documentIdLabel}
|
||||
value={node.id}
|
||||
value={showDocumentTitle ? (docData?.id ?? node.id) : node.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -644,6 +678,7 @@ export const NodeHoverPopover = memo<NodeHoverPopoverProps>(
|
|||
colors={colors}
|
||||
icon={<EyeIcon color={colors.popoverTextMuted} />}
|
||||
label={labels.viewDocument}
|
||||
shortcut={openDocumentShortcut}
|
||||
onClick={() => onOpenDocument(documentId)}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export const ANIMATION = {
|
|||
dimDuration: 1500,
|
||||
}
|
||||
|
||||
export const DEFAULT_COLORS: GraphThemeColors = {
|
||||
export const DEFAULT_COLORS: GraphThemeColors & { edgeDocument: string } = {
|
||||
bg: "#0f1419",
|
||||
docFill: "#1B1F24",
|
||||
docStroke: "#2A2F36",
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export type {
|
|||
MemoryGraphProps,
|
||||
MemoryGraphLabels,
|
||||
MemoryGraphLayering,
|
||||
GraphAppearance,
|
||||
ResolvedMemoryGraphLabels,
|
||||
GraphNode,
|
||||
GraphEdge,
|
||||
|
|
|
|||
|
|
@ -118,7 +118,7 @@ export interface GraphThemeColors {
|
|||
textPrimary: string
|
||||
textSecondary: string
|
||||
textMuted: string
|
||||
edgeDocument: string
|
||||
edgeDocument?: string
|
||||
edgeDerives: string
|
||||
edgeUpdates: string
|
||||
edgeExtends: string
|
||||
|
|
@ -157,12 +157,18 @@ export interface MemoryGraphLayering {
|
|||
hoverPopoverZIndex?: number
|
||||
}
|
||||
|
||||
export interface GraphAppearance {
|
||||
documentNodes?: "icons" | "stacked"
|
||||
connections?: "default" | "emphasized"
|
||||
}
|
||||
|
||||
export interface GraphCanvasProps {
|
||||
nodes: GraphNode[]
|
||||
edges: GraphEdge[]
|
||||
width: number
|
||||
height: number
|
||||
colors: GraphThemeColors
|
||||
appearance?: GraphAppearance
|
||||
highlightDocumentIds?: string[]
|
||||
selectedNodeId?: string | null
|
||||
onNodeHover: (nodeId: string | null) => void
|
||||
|
|
@ -224,6 +230,8 @@ export interface MemoryGraphProps {
|
|||
labels?: MemoryGraphLabels
|
||||
/** Overlay layering controls */
|
||||
layering?: MemoryGraphLayering
|
||||
appearance?: GraphAppearance
|
||||
openDocumentOnEnter?: boolean
|
||||
}
|
||||
|
||||
export interface ChainEntry {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue