/** * @file Provider component for canvas viewport context */ import * as React from "react"; import { bindActionCreators } from "../../../../utils/typedActions"; import { type NodeCanvasState, type NodeCanvasActionsValue, type NodeCanvasApiValue, nodeCanvasActions, NodeCanvasContext, } from "./context"; import { createNodeCanvasStore } from "./store"; import { defaultNodeCanvasState, nodeCanvasReducer } from "./reducer"; export { nodeCanvasReducer, defaultNodeCanvasState }; // Provider export type NodeCanvasProviderProps = { children: React.ReactNode; initialState?: Partial; }; export const NodeCanvasProvider: React.FC = ({ children, initialState }) => { const initialStateRef = React.useRef(null); if (!initialStateRef.current) { initialStateRef.current = { ...defaultNodeCanvasState, ...initialState }; } const store = React.useMemo(() => createNodeCanvasStore(initialStateRef.current!), []); const canvasRef = React.useRef(null); const containerRef = React.useRef(null); const dispatch = store.dispatch; const resizeObserverCleanupRef = React.useRef<(() => void) | null>(null); const dispatchViewBoxIfChanged = React.useCallback( (next: { width: number; height: number }) => { const current = store.getState().viewBox; if (current.width === next.width && current.height === next.height) { return; } store.dispatch(nodeCanvasActions.setViewBox(next)); }, [store], ); const setContainerElement = React.useCallback( (element: HTMLDivElement | null) => { if (resizeObserverCleanupRef.current) { resizeObserverCleanupRef.current(); resizeObserverCleanupRef.current = null; } containerRef.current = element; if (!element) { return; } const rect = element.getBoundingClientRect(); dispatchViewBoxIfChanged({ width: Math.floor(rect.width), height: Math.floor(rect.height) }); if (typeof ResizeObserver === "undefined") { return; } const observer = new ResizeObserver((entries) => { const entry = entries[0]; if (!entry) { return; } // Use floor to avoid sub-pixel churn that can create render loops via repeated notifications. const width = Math.floor(entry.contentRect.width); const height = Math.floor(entry.contentRect.height); dispatchViewBoxIfChanged({ width, height }); }); observer.observe(element, { box: "border-box" }); resizeObserverCleanupRef.current = () => { observer.disconnect(); }; }, [dispatchViewBoxIfChanged], ); const boundActions = React.useMemo(() => bindActionCreators(nodeCanvasActions, dispatch), [dispatch]); // Stable actions value - refs and dispatch are stable const actionsValue = React.useMemo( () => ({ dispatch, actions: boundActions, actionCreators: nodeCanvasActions, canvasRef, containerRef, setContainerElement, }), [dispatch, boundActions, setContainerElement], ); const utils = React.useMemo(() => { return { screenToCanvas: (screenX, screenY) => { const element = containerRef.current ?? canvasRef.current; if (!element) { return { x: screenX, y: screenY }; } const viewport = store.getState().viewport; const rect = element.getBoundingClientRect(); return { x: (screenX - rect.left - viewport.offset.x) / viewport.scale, y: (screenY - rect.top - viewport.offset.y) / viewport.scale, }; }, canvasToScreen: (canvasX, canvasY) => { const element = containerRef.current ?? canvasRef.current; if (!element) { return { x: canvasX, y: canvasY }; } const viewport = store.getState().viewport; const rect = element.getBoundingClientRect(); return { x: canvasX * viewport.scale + viewport.offset.x + rect.left, y: canvasY * viewport.scale + viewport.offset.y + rect.top, }; }, }; }, [store]); const contextValue = React.useMemo( () => ({ ...actionsValue, store, utils, }), [actionsValue, store, utils], ); React.useEffect(() => { return () => { if (resizeObserverCleanupRef.current) { resizeObserverCleanupRef.current(); resizeObserverCleanupRef.current = null; } }; }, []); return ( {children} ); }; /** * Debug notes: * - Reworked provider to use an external store so canvas panning doesn't force re-rendering the full editor subtree. */