@dfosco/storyboard-react 4.2.0-alpha.14 → 4.2.0-alpha.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,223 +0,0 @@
1
- /**
2
- * Embed Controller — manages iframe lifecycle for canvas embed widgets.
3
- *
4
- * Behaviors:
5
- * - Performance mode (per-canvas setting): embeds don't render until clicked
6
- * - Viewport threshold: if >7 embeds visible, none render (zoom in to reduce)
7
- * - Viewport exit: embeds deactivate 5s after leaving the viewport
8
- *
9
- * Usage:
10
- * <EmbedControllerProvider performanceMode={bool} scrollRef={ref}>
11
- * <StoryWidget ... />
12
- * </EmbedControllerProvider>
13
- *
14
- * // Inside a widget:
15
- * const { active, activate } = useEmbedActive(widgetId, containerRef)
16
- */
17
- import { createContext, useContext, useCallback, useEffect, useRef, useSyncExternalStore } from 'react'
18
-
19
- const DEACTIVATE_DELAY = 5000
20
- const MAX_VISIBLE_EMBEDS = 7
21
-
22
- // ── Shared state (module-level, one per page) ──────────────────────────
23
-
24
- let performanceMode = false
25
- let visibleEmbedIds = new Set()
26
- let activeEmbedIds = new Set()
27
- let manuallyActivatedIds = new Set()
28
- let listeners = new Set()
29
-
30
- function notify() {
31
- for (const fn of listeners) fn()
32
- }
33
-
34
- function subscribe(fn) {
35
- listeners.add(fn)
36
- return () => listeners.delete(fn)
37
- }
38
-
39
- function setPerformanceMode(value) {
40
- performanceMode = value
41
- if (value) {
42
- // Entering perf mode: deactivate all non-manually-activated embeds
43
- activeEmbedIds = new Set(manuallyActivatedIds)
44
- }
45
- notify()
46
- }
47
-
48
- function getPerformanceMode() {
49
- return performanceMode
50
- }
51
-
52
- function registerEmbed(id) {
53
- // In normal mode with few embeds, auto-activate
54
- if (!performanceMode && visibleEmbedIds.size <= MAX_VISIBLE_EMBEDS) {
55
- activeEmbedIds.add(id)
56
- }
57
- notify()
58
- }
59
-
60
- function unregisterEmbed(id) {
61
- visibleEmbedIds.delete(id)
62
- activeEmbedIds.delete(id)
63
- manuallyActivatedIds.delete(id)
64
- notify()
65
- }
66
-
67
- function markVisible(id) {
68
- visibleEmbedIds.add(id)
69
- // Auto-activate if not in perf mode and under threshold
70
- if (!performanceMode && visibleEmbedIds.size <= MAX_VISIBLE_EMBEDS) {
71
- activeEmbedIds.add(id)
72
- }
73
- // If was manually activated, keep it active
74
- if (manuallyActivatedIds.has(id)) {
75
- activeEmbedIds.add(id)
76
- }
77
- notify()
78
- }
79
-
80
- function markHidden(id) {
81
- visibleEmbedIds.delete(id)
82
- // Check if other embeds should now activate (dropped below threshold)
83
- if (!performanceMode && visibleEmbedIds.size <= MAX_VISIBLE_EMBEDS) {
84
- for (const vid of visibleEmbedIds) {
85
- activeEmbedIds.add(vid)
86
- }
87
- }
88
- notify()
89
- }
90
-
91
- function deactivateEmbed(id) {
92
- activeEmbedIds.delete(id)
93
- manuallyActivatedIds.delete(id)
94
- notify()
95
- }
96
-
97
- function activateEmbed(id) {
98
- activeEmbedIds.add(id)
99
- manuallyActivatedIds.add(id)
100
- notify()
101
- }
102
-
103
- function isActive(id) {
104
- return activeEmbedIds.has(id)
105
- }
106
-
107
- function isTooManyVisible() {
108
- return visibleEmbedIds.size > MAX_VISIBLE_EMBEDS
109
- }
110
-
111
- // ── React context ──────────────────────────────────────────────────────
112
-
113
- const EmbedControllerContext = createContext(null)
114
-
115
- export function EmbedControllerProvider({ performanceMode: perfModeProp, scrollRef, children }) {
116
- // Sync prop to module state
117
- useEffect(() => {
118
- setPerformanceMode(perfModeProp)
119
- }, [perfModeProp])
120
-
121
- // Reset on unmount
122
- useEffect(() => {
123
- return () => {
124
- visibleEmbedIds = new Set()
125
- activeEmbedIds = new Set()
126
- manuallyActivatedIds = new Set()
127
- performanceMode = false
128
- notify()
129
- }
130
- }, [])
131
-
132
- return (
133
- <EmbedControllerContext.Provider value={scrollRef}>
134
- {children}
135
- </EmbedControllerContext.Provider>
136
- )
137
- }
138
-
139
- /**
140
- * Hook for embed widgets. Returns { active, activate, performanceMode, tooMany }.
141
- * - active: whether the iframe should be rendered
142
- * - activate: call to manually activate (user clicked)
143
- * - performanceMode: whether perf mode is on
144
- * - tooMany: whether there are too many visible embeds
145
- */
146
- export function useEmbedActive(widgetId, containerRef) {
147
- const scrollRef = useContext(EmbedControllerContext)
148
- const deactivateTimerRef = useRef(null)
149
-
150
- // Subscribe to state changes
151
- const snapshot = useSyncExternalStore(subscribe, () => ({
152
- active: isActive(widgetId),
153
- performanceMode: getPerformanceMode(),
154
- tooMany: isTooManyVisible(),
155
- }), () => ({
156
- active: false,
157
- performanceMode: false,
158
- tooMany: false,
159
- }))
160
-
161
- // Need a stable reference check since useSyncExternalStore compares by reference
162
- const activeRef = useRef(false)
163
- const perfRef = useRef(false)
164
- const tooManyRef = useRef(false)
165
-
166
- const active = isActive(widgetId)
167
- const perf = getPerformanceMode()
168
- const tooMany = isTooManyVisible()
169
-
170
- if (activeRef.current !== active || perfRef.current !== perf || tooManyRef.current !== tooMany) {
171
- activeRef.current = active
172
- perfRef.current = perf
173
- tooManyRef.current = tooMany
174
- }
175
-
176
- // Register/unregister
177
- useEffect(() => {
178
- registerEmbed(widgetId)
179
- return () => {
180
- unregisterEmbed(widgetId)
181
- if (deactivateTimerRef.current) clearTimeout(deactivateTimerRef.current)
182
- }
183
- }, [widgetId])
184
-
185
- // IntersectionObserver for viewport tracking
186
- useEffect(() => {
187
- const el = containerRef?.current
188
- if (!el) return
189
-
190
- const root = scrollRef?.current || null
191
-
192
- const observer = new IntersectionObserver(
193
- ([entry]) => {
194
- if (entry.isIntersecting) {
195
- // Entered viewport
196
- if (deactivateTimerRef.current) {
197
- clearTimeout(deactivateTimerRef.current)
198
- deactivateTimerRef.current = null
199
- }
200
- markVisible(widgetId)
201
- } else {
202
- // Left viewport — start deactivation timer
203
- markHidden(widgetId)
204
- if (deactivateTimerRef.current) clearTimeout(deactivateTimerRef.current)
205
- deactivateTimerRef.current = setTimeout(() => {
206
- deactivateEmbed(widgetId)
207
- deactivateTimerRef.current = null
208
- }, DEACTIVATE_DELAY)
209
- }
210
- },
211
- { root, threshold: 0 }
212
- )
213
-
214
- observer.observe(el)
215
- return () => observer.disconnect()
216
- }, [widgetId, containerRef, scrollRef])
217
-
218
- const activate = useCallback(() => {
219
- activateEmbed(widgetId)
220
- }, [widgetId])
221
-
222
- return { active, activate, performanceMode: perf, tooMany }
223
- }