/** * Single subscriber for viewport zoom: one component (ViewportDisplayProvider) * subscribes to useViewport() and provides displayMode so contextual nodes * don't each subscribe to viewport and re-render on every pan/zoom. */ import React, { useLayoutEffect, useRef, useState } from 'react' import { useViewport } from '@xyflow/react' import { CONTEXTUAL_ZOOM_THRESHOLD } from '@/app/canvas/ContextualZoomNode' export type ViewportDisplayMode = 'compact' | 'full' const HYSTERESIS = 0.02 const ViewportDisplayContext = React.createContext('full') export { ViewportDisplayContext } /** * Must be rendered inside ReactFlowProvider. Subscribes to viewport once, * 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 lastModeRef = useRef(displayMode) const zoomRef = useRef(zoom) const rafRef = useRef(null) zoomRef.current = zoom useLayoutEffect(() => { 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 ( {children} ) }