import React, { createContext, useContext, useMemo, useCallback, useSyncExternalStore, useRef, useEffect, } from 'react'; import { useStageStore } from '@/lib/store/stage'; import type { Scene } from '@/lib/types/stage'; import { produce } from 'immer'; interface SceneContextValue { sceneId: string; sceneType: Scene['type']; sceneData: T; updateSceneData: (updater: (draft: T) => void) => void; // Internal: subscribe to scene data changes subscribe: (callback: () => void) => () => void; getSnapshot: () => T; } const SceneContext = createContext(null); /** * Generic Scene Provider * Provides current scene data and update methods to child components * Automatically syncs changes back to stageStore * * Usage: * * // Uses useSceneData() * */ export function SceneProvider({ children }: { children: React.ReactNode }) { // Subscribe to current scene const currentScene = useStageStore((state) => { if (!state.currentSceneId) return null; return state.scenes.find((s) => s.id === state.currentSceneId) || null; }); const updateScene = useStageStore((state) => state.updateScene); const sceneId = currentScene?.id || ''; const sceneType = currentScene?.type || 'slide'; const sceneData = currentScene?.content || null; // Listeners for scene data changes const listenersRef = useRef(new Set<() => void>()); // Subscribe function for child components const subscribe = useCallback((callback: () => void) => { listenersRef.current.add(callback); return () => { listenersRef.current.delete(callback); }; }, []); // Get current snapshot const getSnapshot = useCallback(() => { return sceneData; }, [sceneData]); // Notify all listeners when sceneData changes useEffect(() => { listenersRef.current.forEach((listener) => listener()); }, [sceneData]); // Update scene data with Immer const updateSceneData = useCallback( (updater: (draft: unknown) => void) => { if (!currentScene) return; const newContent = produce(currentScene.content, updater); updateScene(currentScene.id, { content: newContent, }); }, [currentScene, updateScene], ); const value = useMemo( () => ({ sceneId, sceneType, sceneData, updateSceneData, subscribe, getSnapshot, }), [sceneId, sceneType, sceneData, updateSceneData, subscribe, getSnapshot], ); // Don't render anything if there's no scene - let parent component handle this if (!currentScene) { return null; } return {children}; } /** * Hook to access current scene data * Type-safe with generics * * @example * // In SlideRenderer * const { sceneData, updateSceneData } = useSceneData(); * const Canvas = sceneData.Canvas; * * // Update Canvas background * updateSceneData(draft => { * draft.Canvas.background = { type: 'solid', color: '#fff' }; * }); */ export function useSceneData(): SceneContextValue { const context = useContext(SceneContext); if (!context) { throw new Error('useSceneData must be used within SceneProvider'); } return context as SceneContextValue; } /** * Hook to subscribe to a specific part of scene data * **Precise subscription** - only re-renders when the selector return value changes * * How it works: * 1. Uses useSyncExternalStore to subscribe to an external data source * 2. Selector extracts the needed data slice * 3. React auto-performs shallow comparison, only triggering re-render when the return value changes * * @example * // Only subscribes to background; changes to elements won't trigger re-render * const background = useSceneSelector( * content => content.Canvas.background * ); */ export function useSceneSelector(selector: (data: T) => R): R { const context = useContext(SceneContext); if (!context) { throw new Error('useSceneSelector must be used within SceneProvider'); } const { subscribe, getSnapshot } = context as SceneContextValue; // Cache selector and previous result const selectorRef = useRef(selector); const snapshotRef = useRef(undefined); // Update selector ref useEffect(() => { selectorRef.current = selector; }, [selector]); // Use useSyncExternalStore for precise subscription return useSyncExternalStore( subscribe, () => { const snapshot = getSnapshot(); const newValue = selectorRef.current(snapshot); // Shallow comparison optimization: if value hasn't changed, return previous reference if (snapshotRef.current !== undefined && shallowEqual(snapshotRef.current, newValue)) { return snapshotRef.current; } snapshotRef.current = newValue; return newValue; }, () => { // SSR fallback const snapshot = getSnapshot(); return selectorRef.current(snapshot); }, ); } /** * Shallow comparison function * Used to optimize re-renders in useSceneSelector */ function shallowEqual(a: unknown, b: unknown): boolean { if (Object.is(a, b)) { return true; } if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) { return false; } const objA = a as Record; const objB = b as Record; const keysA = Object.keys(objA); const keysB = Object.keys(objB); if (keysA.length !== keysB.length) { return false; } for (const key of keysA) { if (!Object.prototype.hasOwnProperty.call(objB, key) || !Object.is(objA[key], objB[key])) { return false; } } return true; }