scene-context.tsx 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. 'use client';
  2. import React, {
  3. createContext,
  4. useContext,
  5. useMemo,
  6. useCallback,
  7. useSyncExternalStore,
  8. useRef,
  9. useEffect,
  10. } from 'react';
  11. import { useStageStore } from '@/lib/store/stage';
  12. import type { Scene } from '@/lib/types/stage';
  13. import { produce } from 'immer';
  14. interface SceneContextValue<T = unknown> {
  15. sceneId: string;
  16. sceneType: Scene['type'];
  17. sceneData: T;
  18. updateSceneData: (updater: (draft: T) => void) => void;
  19. // Internal: subscribe to scene data changes
  20. subscribe: (callback: () => void) => () => void;
  21. getSnapshot: () => T;
  22. }
  23. const SceneContext = createContext<SceneContextValue | null>(null);
  24. /**
  25. * Generic Scene Provider
  26. * Provides current scene data and update methods to child components
  27. * Automatically syncs changes back to stageStore
  28. *
  29. * Usage:
  30. * <SceneProvider>
  31. * <SlideRenderer /> // Uses useSceneData<SlideContent>()
  32. * </SceneProvider>
  33. */
  34. export function SceneProvider({ children }: { children: React.ReactNode }) {
  35. // Subscribe to current scene
  36. const currentScene = useStageStore((state) => {
  37. if (!state.currentSceneId) return null;
  38. return state.scenes.find((s) => s.id === state.currentSceneId) || null;
  39. });
  40. const updateScene = useStageStore((state) => state.updateScene);
  41. const sceneId = currentScene?.id || '';
  42. const sceneType = currentScene?.type || 'slide';
  43. const sceneData = currentScene?.content || null;
  44. // Listeners for scene data changes
  45. const listenersRef = useRef(new Set<() => void>());
  46. // Subscribe function for child components
  47. const subscribe = useCallback((callback: () => void) => {
  48. listenersRef.current.add(callback);
  49. return () => {
  50. listenersRef.current.delete(callback);
  51. };
  52. }, []);
  53. // Get current snapshot
  54. const getSnapshot = useCallback(() => {
  55. return sceneData;
  56. }, [sceneData]);
  57. // Notify all listeners when sceneData changes
  58. useEffect(() => {
  59. listenersRef.current.forEach((listener) => listener());
  60. }, [sceneData]);
  61. // Update scene data with Immer
  62. const updateSceneData = useCallback(
  63. (updater: (draft: unknown) => void) => {
  64. if (!currentScene) return;
  65. const newContent = produce(currentScene.content, updater);
  66. updateScene(currentScene.id, {
  67. content: newContent,
  68. });
  69. },
  70. [currentScene, updateScene],
  71. );
  72. const value = useMemo(
  73. () => ({
  74. sceneId,
  75. sceneType,
  76. sceneData,
  77. updateSceneData,
  78. subscribe,
  79. getSnapshot,
  80. }),
  81. [sceneId, sceneType, sceneData, updateSceneData, subscribe, getSnapshot],
  82. );
  83. // Don't render anything if there's no scene - let parent component handle this
  84. if (!currentScene) {
  85. return null;
  86. }
  87. return <SceneContext.Provider value={value}>{children}</SceneContext.Provider>;
  88. }
  89. /**
  90. * Hook to access current scene data
  91. * Type-safe with generics
  92. *
  93. * @example
  94. * // In SlideRenderer
  95. * const { sceneData, updateSceneData } = useSceneData<SlideContent>();
  96. * const Canvas = sceneData.Canvas;
  97. *
  98. * // Update Canvas background
  99. * updateSceneData(draft => {
  100. * draft.Canvas.background = { type: 'solid', color: '#fff' };
  101. * });
  102. */
  103. export function useSceneData<T = unknown>(): SceneContextValue<T> {
  104. const context = useContext(SceneContext);
  105. if (!context) {
  106. throw new Error('useSceneData must be used within SceneProvider');
  107. }
  108. return context as SceneContextValue<T>;
  109. }
  110. /**
  111. * Hook to subscribe to a specific part of scene data
  112. * **Precise subscription** - only re-renders when the selector return value changes
  113. *
  114. * How it works:
  115. * 1. Uses useSyncExternalStore to subscribe to an external data source
  116. * 2. Selector extracts the needed data slice
  117. * 3. React auto-performs shallow comparison, only triggering re-render when the return value changes
  118. *
  119. * @example
  120. * // Only subscribes to background; changes to elements won't trigger re-render
  121. * const background = useSceneSelector<SlideContent>(
  122. * content => content.Canvas.background
  123. * );
  124. */
  125. export function useSceneSelector<T = unknown, R = unknown>(selector: (data: T) => R): R {
  126. const context = useContext(SceneContext);
  127. if (!context) {
  128. throw new Error('useSceneSelector must be used within SceneProvider');
  129. }
  130. const { subscribe, getSnapshot } = context as SceneContextValue<T>;
  131. // Cache selector and previous result
  132. const selectorRef = useRef(selector);
  133. const snapshotRef = useRef<R | undefined>(undefined);
  134. // Update selector ref
  135. useEffect(() => {
  136. selectorRef.current = selector;
  137. }, [selector]);
  138. // Use useSyncExternalStore for precise subscription
  139. return useSyncExternalStore(
  140. subscribe,
  141. () => {
  142. const snapshot = getSnapshot();
  143. const newValue = selectorRef.current(snapshot);
  144. // Shallow comparison optimization: if value hasn't changed, return previous reference
  145. if (snapshotRef.current !== undefined && shallowEqual(snapshotRef.current, newValue)) {
  146. return snapshotRef.current;
  147. }
  148. snapshotRef.current = newValue;
  149. return newValue;
  150. },
  151. () => {
  152. // SSR fallback
  153. const snapshot = getSnapshot();
  154. return selectorRef.current(snapshot);
  155. },
  156. );
  157. }
  158. /**
  159. * Shallow comparison function
  160. * Used to optimize re-renders in useSceneSelector
  161. */
  162. function shallowEqual(a: unknown, b: unknown): boolean {
  163. if (Object.is(a, b)) {
  164. return true;
  165. }
  166. if (typeof a !== 'object' || a === null || typeof b !== 'object' || b === null) {
  167. return false;
  168. }
  169. const objA = a as Record<string, unknown>;
  170. const objB = b as Record<string, unknown>;
  171. const keysA = Object.keys(objA);
  172. const keysB = Object.keys(objB);
  173. if (keysA.length !== keysB.length) {
  174. return false;
  175. }
  176. for (const key of keysA) {
  177. if (!Object.prototype.hasOwnProperty.call(objB, key) || !Object.is(objA[key], objB[key])) {
  178. return false;
  179. }
  180. }
  181. return true;
  182. }