diff --git a/frontend/src/app/canvas/CanvasPage.tsx b/frontend/src/app/canvas/CanvasPage.tsx index ba4b1e1..dc3d071 100644 --- a/frontend/src/app/canvas/CanvasPage.tsx +++ b/frontend/src/app/canvas/CanvasPage.tsx @@ -31,7 +31,7 @@ import { import { useTheme } from '@/lib/themeContext' import { usePlatform } from '@/app/kosmos/KosmosContext' import { useCanvasGraph } from '@/app/canvas/useCanvasGraph' -import { getExampleGraph } from '@/app/canvas/canvasGraphUtils' +import { getExampleGraph, backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils' import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu' import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent' import { useCanvasConnectionPath } from '@/app/canvas/useCanvasConnectionPath' @@ -189,6 +189,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) { const nodesRef = useRef(nodes) nodesRef.current = nodes + const graphRef = useRef<{ nodes: AppNode[]; edges: AppEdge[] }>({ nodes: [], edges: [] }) + graphRef.current.nodes = nodes + graphRef.current.edges = edges const [apiTodosCount, setApiTodosCount] = React.useState(null) React.useEffect(() => { @@ -244,7 +247,12 @@ export function CanvasPage({ projectId }: CanvasPageProps) { ) const onConnect = useCallback( - (params: Connection) => setEdges((eds) => addEdge(params, eds)), + (params: Connection) => { + const targetType = + nodesRef.current.find((n) => n.id === params.target)?.type ?? '' + const conn = { ...params, data: { targetType } as Record } + setEdges((eds) => addEdge(conn, eds)) + }, [setEdges] ) @@ -324,7 +332,9 @@ export function CanvasPage({ projectId }: CanvasPageProps) { toast.error('Invalid file: expected nodes and edges arrays') return } - setStateImmediate({ nodes: state.nodes as AppNode[], edges: state.edges as AppEdge[] }) + const nodes = state.nodes as AppNode[] + const edges = backfillEdgeTargetTypes(nodes, state.edges as AppEdge[]) + setStateImmediate({ nodes, edges }) if (state.version != null && state.version > PROJECT_VERSION) { toast.error('Project was created with a newer app version') } else { @@ -393,8 +403,8 @@ export function CanvasPage({ projectId }: CanvasPageProps) { }, []) const graphContextValue = useMemo( - () => ({ nodes, setNodes, edges, setEdges }), - [nodes, setNodes, edges, setEdges] + () => ({ setNodes, setEdges, graphRef, edges }), + [setNodes, setEdges, edges] ) const connectionPathContextValue = useMemo( () => ({ @@ -438,18 +448,36 @@ export function CanvasPage({ projectId }: CanvasPageProps) { ] ) - const nodesForFlow = useMemo( - () => - nodes.map((n) => ({ + const prevNodesRef = useRef([]) + const prevNodesForFlowRef = useRef([]) + const nodesForFlow = useMemo(() => { + const prev = prevNodesRef.current + if (nodes === prev) return prevNodesForFlowRef.current + const prevById = new Map(prev.map((n) => [n.id, n])) + const prevWrappedById = new Map( + prevNodesForFlowRef.current.map((w, i) => [prev[i]?.id, w]) + ) + const result = nodes.map((n) => { + const prevNode = prevById.get(n.id) + if (prevNode === n && prevWrappedById.has(n.id)) { + return prevWrappedById.get(n.id)! + } + return { ...n, className: [n.className, 'nowheel'].filter(Boolean).join(' '), - })), - [nodes] - ) + } + }) + prevNodesRef.current = nodes + prevNodesForFlowRef.current = result + return result + }, [nodes]) const edgesForFlow = useMemo( () => edges.map((e) => { - const targetType = nodes.find((nd) => nd.id === e.target)?.type ?? '' + const targetType = + (typeof e.data === 'object' && e.data !== null && (e.data as Record).targetType != null + ? (e.data as Record).targetType + : '') as string const connectionLabel = getConnectionLabelForTarget(targetType) const baseData = typeof e.data === 'object' && e.data !== null ? (e.data as Record) : {} @@ -458,7 +486,7 @@ export function CanvasPage({ projectId }: CanvasPageProps) { data: { ...baseData, connectionLabel }, } }), - [edges, nodes] + [edges] ) const onContextMenuCapture = useCallback((ev: React.MouseEvent) => { diff --git a/frontend/src/app/canvas/ViewportDisplayContext.tsx b/frontend/src/app/canvas/ViewportDisplayContext.tsx index ce98809..6f34ac9 100644 --- a/frontend/src/app/canvas/ViewportDisplayContext.tsx +++ b/frontend/src/app/canvas/ViewportDisplayContext.tsx @@ -17,25 +17,34 @@ export { ViewportDisplayContext } /** * Must be rendered inside ReactFlowProvider. Subscribes to viewport once, - * maps zoom to displayMode with hysteresis, and provides it to descendants. + * maps zoom to displayMode with hysteresis. Throttles updates via rAF to avoid + * re-rendering all contextual nodes on every zoom tick. */ export function ViewportDisplayProvider({ children }: { children: React.ReactNode }) { const { zoom } = useViewport() const [displayMode, setDisplayMode] = useState(() => zoom <= CONTEXTUAL_ZOOM_THRESHOLD ? 'compact' : 'full' ) - const lastRef = useRef(displayMode) + const lastModeRef = useRef(displayMode) + const zoomRef = useRef(zoom) + const rafRef = useRef(null) + zoomRef.current = zoom useLayoutEffect(() => { - const low = CONTEXTUAL_ZOOM_THRESHOLD - HYSTERESIS - const high = CONTEXTUAL_ZOOM_THRESHOLD + HYSTERESIS - let next: ViewportDisplayMode = lastRef.current - if (zoom <= low) next = 'compact' - else if (zoom >= high) next = 'full' - if (next !== lastRef.current) { - lastRef.current = next - setDisplayMode(next) - } + if (rafRef.current !== null) return + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null + const z = zoomRef.current + const low = CONTEXTUAL_ZOOM_THRESHOLD - HYSTERESIS + const high = CONTEXTUAL_ZOOM_THRESHOLD + HYSTERESIS + let next: ViewportDisplayMode = lastModeRef.current + if (z <= low) next = 'compact' + else if (z >= high) next = 'full' + if (next !== lastModeRef.current) { + lastModeRef.current = next + setDisplayMode(next) + } + }) }, [zoom]) return ( diff --git a/frontend/src/app/canvas/canvasGraphUtils.ts b/frontend/src/app/canvas/canvasGraphUtils.ts index f5fa9d6..d5ad5e6 100644 --- a/frontend/src/app/canvas/canvasGraphUtils.ts +++ b/frontend/src/app/canvas/canvasGraphUtils.ts @@ -7,6 +7,20 @@ import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes' import { DEFAULT_NODE_STYLE } from '@/lib/graph/flowUtils' import { loadGraphFromStorage } from '@/app/pleroma/projectGraphStorage' +/** Ensure each edge has data.targetType from the target node (for labels without depending on nodes in edgesForFlow). */ +export function backfillEdgeTargetTypes( + nodes: AppNode[], + edges: AppEdge[] +): AppEdge[] { + const typeById = new Map(nodes.map((n) => [n.id, n.type ?? ''])) + return edges.map((e) => { + const targetType = typeById.get(e.target) ?? (e.data as Record)?.targetType ?? '' + const data = typeof e.data === 'object' && e.data !== null ? (e.data as Record) : {} + if (data.targetType === targetType) return e + return { ...e, data: { ...data, targetType } } + }) +} + const NODE_GAP = 150 const EXAMPLE_NODES: AppNode[] = [ @@ -45,20 +59,24 @@ const EXAMPLE_EDGES: AppEdge[] = [ ] export function getExampleGraph(): { nodes: AppNode[]; edges: AppEdge[] } { - return { - nodes: EXAMPLE_NODES.map((n) => ({ - ...n, - data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data, - })), - edges: EXAMPLE_EDGES.map((e) => ({ ...e })), - } + const nodes = EXAMPLE_NODES.map((n) => ({ + ...n, + data: n.data && typeof n.data === 'object' ? { ...(n.data as object) } : n.data, + })) + const edges = backfillEdgeTargetTypes( + nodes, + EXAMPLE_EDGES.map((e) => ({ ...e })) + ) + return { nodes, edges } } export function getInitialGraph(projectId: string | undefined): { nodes: AppNode[]; edges: AppEdge[] } { if (projectId) { const stored = loadGraphFromStorage(projectId) if (stored && (stored.nodes.length > 0 || stored.edges.length > 0)) { - return { nodes: stored.nodes as AppNode[], edges: stored.edges as AppEdge[] } + const nodes = stored.nodes as AppNode[] + const edges = backfillEdgeTargetTypes(nodes, stored.edges as AppEdge[]) + return { nodes, edges } } return { nodes: [], edges: [] } } diff --git a/frontend/src/components/graph/FlowKeyboardShortcuts.tsx b/frontend/src/components/graph/FlowKeyboardShortcuts.tsx index 91a4480..9011231 100644 --- a/frontend/src/components/graph/FlowKeyboardShortcuts.tsx +++ b/frontend/src/components/graph/FlowKeyboardShortcuts.tsx @@ -16,7 +16,6 @@ export function FlowKeyboardShortcuts() { const { fitView, screenToFlowPosition } = useReactFlow() const graphCtx = useContext(GraphContext) const uiCtx = useContext(FlowUIContext) - const nodes = graphCtx?.nodes ?? [] const setNodes = graphCtx?.setNodes const setConnectionFrom = uiCtx?.setConnectionFrom const flowActionsRef = uiCtx?.flowActionsRef @@ -80,6 +79,7 @@ export function FlowKeyboardShortcuts() { useEffect(() => { const onKeyDown = (ev: KeyboardEvent) => { + const nodes = graphCtx?.graphRef?.current?.nodes ?? [] if (ev.key === 'Escape') { const openDialog = document.querySelector('[role="dialog"]') if (openDialog && ev.target instanceof Node && openDialog.contains(ev.target)) return @@ -152,7 +152,7 @@ export function FlowKeyboardShortcuts() { window.addEventListener('keydown', onKeyDown, true) return () => window.removeEventListener('keydown', onKeyDown, true) }, [ - nodes, + graphCtx?.graphRef, setNodes, setConnectionFrom, pasteAtViewportCenter, diff --git a/frontend/src/components/graph/NodeHeaderTitle.tsx b/frontend/src/components/graph/NodeHeaderTitle.tsx index 43d4e5e..00cf7f9 100644 --- a/frontend/src/components/graph/NodeHeaderTitle.tsx +++ b/frontend/src/components/graph/NodeHeaderTitle.tsx @@ -11,9 +11,7 @@ type Props = { export function NodeHeaderTitle({ nodeId, displayTitle }: Props) { const graphCtx = useContext(GraphContext) const uiCtx = useContext(FlowUIContext) - const nodes = graphCtx?.nodes ?? [] const setNodes = graphCtx?.setNodes - const edges = graphCtx?.edges ?? [] const setEdges = graphCtx?.setEdges const renamingNodeId = uiCtx?.renamingNodeId ?? null const setRenamingNodeId = uiCtx?.setRenamingNodeId @@ -32,7 +30,9 @@ export function NodeHeaderTitle({ nodeId, displayTitle }: Props) { }, [isRenaming, nodeId]) const applyRename = useCallback(() => { - if (!setNodes || !setEdges || !setRenamingNodeId) return + if (!setNodes || !setEdges || !setRenamingNodeId || !graphCtx?.graphRef) return + const nodes = graphCtx.graphRef.current.nodes + const edges = graphCtx.graphRef.current.edges const newId = inputValue.trim() if (!newId || newId === nodeId) { setRenamingNodeId(null) @@ -46,7 +46,7 @@ export function NodeHeaderTitle({ nodeId, displayTitle }: Props) { setNodes(nextNodes as AppNode[]) setEdges(nextEdges) setRenamingNodeId(null) - }, [nodeId, inputValue, nodes, edges, setNodes, setEdges, setRenamingNodeId]) + }, [nodeId, inputValue, graphCtx?.graphRef, setNodes, setEdges, setRenamingNodeId]) const cancelRename = useCallback(() => { setRenamingNodeId?.(null) diff --git a/frontend/src/components/graph/NodeMenubar.tsx b/frontend/src/components/graph/NodeMenubar.tsx index f4f8320..7d7cc1b 100644 --- a/frontend/src/components/graph/NodeMenubar.tsx +++ b/frontend/src/components/graph/NodeMenubar.tsx @@ -45,10 +45,9 @@ type Props = { export function NodeMenubar({ nodeId, nodeType, editInputsContent, inputsMenuContent, insertTagsContent, insertMenuLabel = 'Insert', insertContentDirect, insertTagsLabel = 'Tags', nodeMenuExtraContent: nodeMenuExtraContentProp, outputMenuContent, dataMenuContent }: Props) { const graphCtx = useContext(GraphContext) const uiCtx = useContext(FlowUIContext) - const nodes = graphCtx?.nodes ?? [] + const nodes = graphCtx?.graphRef?.current?.nodes ?? [] const setNodes = graphCtx?.setNodes const setEdges = graphCtx?.setEdges - const edges = graphCtx?.edges ?? [] const node = nodes.find((n: any) => n.id === nodeId) const nodeMenuExtraContent = useMemo( diff --git a/frontend/src/components/nodes/render/useRenderingNodeState.ts b/frontend/src/components/nodes/render/useRenderingNodeState.ts index 9fca102..183cab6 100644 --- a/frontend/src/components/nodes/render/useRenderingNodeState.ts +++ b/frontend/src/components/nodes/render/useRenderingNodeState.ts @@ -4,7 +4,7 @@ * pipeline interface. */ -import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useContext, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import { useAbstractNode } from '@/lib/graph/abstractNode' import { ConnectionPathContext } from '@/lib/graph/flowContext' import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle' @@ -118,6 +118,8 @@ export function useRenderingNodeState( const viewportWidth = data?.viewportWidth ?? DEFAULT_VIEWPORT_WIDTH const viewportHeight = data?.viewportHeight ?? DEFAULT_VIEWPORT_HEIGHT + const deferredNodes = useDeferredValue(nodes) + const deferredEdges = useDeferredValue(edges) const srcId = incomingIds.length > 0 ? incomingIds[0] : null const srcNode = useMemo( () => (srcId ? nodes.find((n: { id: string }) => n.id === srcId) : null), @@ -150,8 +152,8 @@ export function useRenderingNodeState( : '' const signatures = useMemo( - () => buildSourceSignatures(nodes as NodeLike[], edges as EdgeLike[], id, incomingIds), - [nodes, edges, id, incomingIds] + () => buildSourceSignatures(deferredNodes as NodeLike[], deferredEdges as EdgeLike[], id, incomingIds), + [deferredNodes, deferredEdges, id, incomingIds] ) const { connectedNodeIds, diff --git a/frontend/src/lib/graph/abstractNode.ts b/frontend/src/lib/graph/abstractNode.ts index d847577..9d6d94d 100644 --- a/frontend/src/lib/graph/abstractNode.ts +++ b/frontend/src/lib/graph/abstractNode.ts @@ -82,7 +82,7 @@ export function useAbstractNode>( ): AbstractNodeContext { const graphCtx = useContext(GraphContext) const pathCtx = useContext(ConnectionPathContext) - const nodes = graphCtx?.nodes ?? [] + const nodes = graphCtx?.graphRef?.current?.nodes ?? [] const edges = graphCtx?.edges ?? [] const setNodes = graphCtx?.setNodes const setEdges = graphCtx?.setEdges diff --git a/frontend/src/lib/graph/flowContext.tsx b/frontend/src/lib/graph/flowContext.tsx index d48391c..63b7d74 100644 --- a/frontend/src/lib/graph/flowContext.tsx +++ b/frontend/src/lib/graph/flowContext.tsx @@ -25,14 +25,20 @@ export type FlowActions = { } // --------------------------------------------------------------------------- -// Graph context (nodes, edges, setters) +// Graph context (setters + graphRef for reads; edges in context so edge changes trigger re-renders) // --------------------------------------------------------------------------- +export type GraphContextRef = { + current: { nodes: AppNode[]; edges: AppEdge[] } +} + export type GraphContextValue = { - nodes: AppNode[] setNodes: (updater: AppNode[] | ((prev: AppNode[]) => AppNode[])) => void - edges: AppEdge[] setEdges: (updater: AppEdge[] | ((prev: AppEdge[]) => AppEdge[])) => void + /** Current nodes/edges; updated every render. Read from here to avoid re-rendering on position-only changes. */ + graphRef: GraphContextRef + /** Edges in context so consumers (e.g. edge indicators, useAbstractNode) re-render when edges change. */ + edges: AppEdge[] } const GraphContext = React.createContext(null)