File size: 5,825 Bytes
f56a29b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212


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<T = unknown> {
  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<SceneContextValue | null>(null);

/**
 * Generic Scene Provider
 * Provides current scene data and update methods to child components
 * Automatically syncs changes back to stageStore
 *
 * Usage:
 * <SceneProvider>
 *   <SlideRenderer /> // Uses useSceneData<SlideContent>()
 * </SceneProvider>
 */
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 <SceneContext.Provider value={value}>{children}</SceneContext.Provider>;
}

/**
 * Hook to access current scene data
 * Type-safe with generics
 *
 * @example
 * // In SlideRenderer
 * const { sceneData, updateSceneData } = useSceneData<SlideContent>();
 * const Canvas = sceneData.Canvas;
 *
 * // Update Canvas background
 * updateSceneData(draft => {
 *   draft.Canvas.background = { type: 'solid', color: '#fff' };
 * });
 */
export function useSceneData<T = unknown>(): SceneContextValue<T> {
  const context = useContext(SceneContext);
  if (!context) {
    throw new Error('useSceneData must be used within SceneProvider');
  }
  return context as SceneContextValue<T>;
}

/**
 * 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<SlideContent>(
 *   content => content.Canvas.background
 * );
 */
export function useSceneSelector<T = unknown, R = unknown>(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<T>;

  // Cache selector and previous result
  const selectorRef = useRef(selector);
  const snapshotRef = useRef<R | undefined>(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<string, unknown>;
  const objB = b as Record<string, unknown>;
  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;
}