@dfosco/storyboard-react 4.2.0-alpha.11 → 4.2.0-alpha.13
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.
- package/package.json +3 -3
- package/src/canvas/CanvasPage.jsx +28 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +16 -1
- package/src/canvas/widgets/PrototypeEmbed.module.css +6 -0
- package/src/canvas/widgets/StoryWidget.jsx +18 -1
- package/src/canvas/widgets/StoryWidget.module.css +6 -0
- package/src/canvas/widgets/TerminalWidget.jsx +231 -40
- package/src/canvas/widgets/TerminalWidget.module.css +105 -0
- package/src/canvas/widgets/useEmbedController.jsx +223 -0
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.2.0-alpha.
|
|
3
|
+
"version": "4.2.0-alpha.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@base-ui/react": "^1.4.0",
|
|
7
|
-
"@dfosco/storyboard-core": "4.2.0-alpha.
|
|
8
|
-
"@dfosco/tiny-canvas": "4.2.0-alpha.
|
|
7
|
+
"@dfosco/storyboard-core": "4.2.0-alpha.13",
|
|
8
|
+
"@dfosco/tiny-canvas": "4.2.0-alpha.13",
|
|
9
9
|
"@neodrag/react": "^2.3.1",
|
|
10
10
|
"glob": "^11.0.0",
|
|
11
11
|
"jsonc-parser": "^3.3.1",
|
|
@@ -12,6 +12,7 @@ import { getPasteRules } from '@dfosco/storyboard-core'
|
|
|
12
12
|
import { registerSmoothCorners } from '@dfosco/storyboard-core/smooth-corners'
|
|
13
13
|
import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
|
|
14
14
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
15
|
+
import { EmbedControllerProvider } from './widgets/useEmbedController.jsx'
|
|
15
16
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
16
17
|
import useUndoRedo from './useUndoRedo.js'
|
|
17
18
|
import useMarqueeSelect from './useMarqueeSelect.js'
|
|
@@ -530,6 +531,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
530
531
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
531
532
|
const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
|
|
532
533
|
const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
|
|
534
|
+
const [perfMode, setPerfMode] = useState(canvas?.performanceMode ?? false)
|
|
533
535
|
const [showGhInstallBanner, setShowGhInstallBanner] = useState(false)
|
|
534
536
|
|
|
535
537
|
// Refs for snap settings (used by drop handler inside effect closure)
|
|
@@ -670,6 +672,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
670
672
|
setLocalSources(canvas?.sources ?? [])
|
|
671
673
|
setSnapEnabled(canvas?.snapToGrid ?? false)
|
|
672
674
|
setSnapGridSize(canvas?.gridSize || 40)
|
|
675
|
+
setPerfMode(canvas?.performanceMode ?? false)
|
|
673
676
|
undoRedo.reset()
|
|
674
677
|
// Only reset viewport state when switching to a different canvas,
|
|
675
678
|
// not when the same canvas refreshes with server data.
|
|
@@ -1549,6 +1552,21 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1549
1552
|
return () => document.removeEventListener('storyboard:canvas:toggle-snap', handleSnapToggle)
|
|
1550
1553
|
}, [canvasId])
|
|
1551
1554
|
|
|
1555
|
+
// Listen for performance mode toggle from command palette
|
|
1556
|
+
useEffect(() => {
|
|
1557
|
+
function handlePerfToggle() {
|
|
1558
|
+
setPerfMode((prev) => {
|
|
1559
|
+
const next = !prev
|
|
1560
|
+
updateCanvas(canvasId, { settings: { performanceMode: next } }).catch((err) =>
|
|
1561
|
+
console.error('[canvas] Failed to persist performance mode:', err)
|
|
1562
|
+
)
|
|
1563
|
+
return next
|
|
1564
|
+
})
|
|
1565
|
+
}
|
|
1566
|
+
document.addEventListener('storyboard:canvas:toggle-performance-mode', handlePerfToggle)
|
|
1567
|
+
return () => document.removeEventListener('storyboard:canvas:toggle-performance-mode', handlePerfToggle)
|
|
1568
|
+
}, [canvasId])
|
|
1569
|
+
|
|
1552
1570
|
// Broadcast snap state to Svelte toolbar
|
|
1553
1571
|
useEffect(() => {
|
|
1554
1572
|
document.dispatchEvent(new CustomEvent('storyboard:canvas:snap-state', {
|
|
@@ -1649,6 +1667,14 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1649
1667
|
}))
|
|
1650
1668
|
}, [canvasId, zoom])
|
|
1651
1669
|
|
|
1670
|
+
// Keep bridge in sync with widgets/connectors for expand features
|
|
1671
|
+
useEffect(() => {
|
|
1672
|
+
const bridge = window[CANVAS_BRIDGE_STATE_KEY] || {}
|
|
1673
|
+
bridge.widgets = localWidgets
|
|
1674
|
+
bridge.connectors = localConnectors
|
|
1675
|
+
window[CANVAS_BRIDGE_STATE_KEY] = bridge
|
|
1676
|
+
}, [localWidgets, localConnectors])
|
|
1677
|
+
|
|
1652
1678
|
// Delete selected widget on Delete/Backspace key
|
|
1653
1679
|
useEffect(() => {
|
|
1654
1680
|
function handleSelectStart(e) {
|
|
@@ -2403,9 +2429,11 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
2403
2429
|
dragPreview={connectorDrag}
|
|
2404
2430
|
hidden={widgetDragging}
|
|
2405
2431
|
/>
|
|
2432
|
+
<EmbedControllerProvider performanceMode={perfMode} scrollRef={scrollRef}>
|
|
2406
2433
|
<Canvas {...canvasProps} onDragStart={isLocalDev ? handleItemDragStart : undefined} onDrag={isLocalDev ? handleItemDrag : undefined} onDragEnd={isLocalDev ? handleItemDragEnd : undefined}>
|
|
2407
2434
|
{allChildren}
|
|
2408
2435
|
</Canvas>
|
|
2436
|
+
</EmbedControllerProvider>
|
|
2409
2437
|
</div>
|
|
2410
2438
|
</div>
|
|
2411
2439
|
{showGhInstallBanner && (
|
|
@@ -6,6 +6,7 @@ import ResizeHandle from './ResizeHandle.jsx'
|
|
|
6
6
|
import { readProp, prototypeEmbedSchema } from './widgetProps.js'
|
|
7
7
|
import { getEmbedChromeVars } from './embedTheme.js'
|
|
8
8
|
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
9
|
+
import { useEmbedActive } from './useEmbedController.jsx'
|
|
9
10
|
import styles from './PrototypeEmbed.module.css'
|
|
10
11
|
import overlayStyles from './embedOverlay.module.css'
|
|
11
12
|
|
|
@@ -67,6 +68,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
67
68
|
const [expanded, setExpanded] = useState(false)
|
|
68
69
|
const [filter, setFilter] = useState('')
|
|
69
70
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
71
|
+
const { active: embedActive, activate: activateEmbed, performanceMode, tooMany } = useEmbedActive(widgetId, embedRef)
|
|
70
72
|
const inputRef = useRef(null)
|
|
71
73
|
const filterRef = useRef(null)
|
|
72
74
|
const embedRef = useRef(null)
|
|
@@ -84,6 +86,8 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
84
86
|
return `${base}${sep}_sb_embed&_sb_hide_branch_bar&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
|
|
85
87
|
}, [rawSrc, canvasTheme])
|
|
86
88
|
|
|
89
|
+
const inactive = !embedActive
|
|
90
|
+
|
|
87
91
|
const prototypeIndex = useMemo(() => {
|
|
88
92
|
try { return buildPrototypeIndex() }
|
|
89
93
|
catch { return { folders: [], prototypes: [], globalFlows: [], sorted: { title: { prototypes: [], folders: [] } } } }
|
|
@@ -295,7 +299,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
295
299
|
className={styles.embed}
|
|
296
300
|
style={{ width, height, ...chromeVars }}
|
|
297
301
|
>
|
|
298
|
-
<div className={styles.header}>
|
|
302
|
+
<div className={`${styles.header}${inactive ? ` ${styles.headerPaused}` : ''}`}>
|
|
299
303
|
<span className={styles.headerIcon}><CollageFrameIcon size={16} /></span>
|
|
300
304
|
<span className={styles.headerTitle}>{prototypeTitle}</span>
|
|
301
305
|
</div>
|
|
@@ -353,6 +357,17 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
353
357
|
</div>
|
|
354
358
|
</form>
|
|
355
359
|
</div>
|
|
360
|
+
) : iframeSrc && inactive ? (
|
|
361
|
+
<div
|
|
362
|
+
className={overlayStyles.interactOverlay}
|
|
363
|
+
onClick={() => { activateEmbed(); enterInteractive() }}
|
|
364
|
+
role="button"
|
|
365
|
+
tabIndex={0}
|
|
366
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { activateEmbed(); enterInteractive() } }}
|
|
367
|
+
aria-label="Click to refresh"
|
|
368
|
+
>
|
|
369
|
+
<span className={overlayStyles.interactHint}>Click to refresh</span>
|
|
370
|
+
</div>
|
|
356
371
|
) : iframeSrc ? (
|
|
357
372
|
<>
|
|
358
373
|
<div
|
|
@@ -33,6 +33,12 @@
|
|
|
33
33
|
text-overflow: ellipsis;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
.headerPaused {
|
|
37
|
+
background: var(--bgColor-attention-muted, #fff8c5);
|
|
38
|
+
color: var(--fgColor-attention, #9a6700);
|
|
39
|
+
border-bottom-color: var(--borderColor-attention-muted, #d4a72c66);
|
|
40
|
+
}
|
|
41
|
+
|
|
36
42
|
.iframeContainer {
|
|
37
43
|
position: relative;
|
|
38
44
|
width: 100%;
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
|
|
12
12
|
import { getStoryData } from '@dfosco/storyboard-core'
|
|
13
|
+
import { useEmbedActive } from './useEmbedController.jsx'
|
|
13
14
|
import { createInspectorHighlighter } from '@dfosco/storyboard-core/inspector/highlighter'
|
|
14
15
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
15
16
|
import ResizeHandle from './ResizeHandle.jsx'
|
|
@@ -65,6 +66,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
65
66
|
const [highlightedHtml, setHighlightedHtml] = useState(null)
|
|
66
67
|
const [sourceLoading, setSourceLoading] = useState(false)
|
|
67
68
|
const [storyIndexKey, setStoryIndexKey] = useState(0)
|
|
69
|
+
const { active: embedActive, activate: activateEmbed, performanceMode, tooMany } = useEmbedActive(widgetId, containerRef)
|
|
68
70
|
|
|
69
71
|
// Re-resolve story URL when the story index is live-patched
|
|
70
72
|
useEffect(() => {
|
|
@@ -171,6 +173,9 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
171
173
|
[storyId, exportName, storyIndexKey],
|
|
172
174
|
)
|
|
173
175
|
|
|
176
|
+
// Only render iframe when embed is active (controlled by EmbedController)
|
|
177
|
+
const shouldRenderIframe = embedActive && iframeSrc
|
|
178
|
+
|
|
174
179
|
useIframeDevLogs({
|
|
175
180
|
widget: 'StoryWidget',
|
|
176
181
|
loaded: interactive && !showCode && Boolean(iframeSrc),
|
|
@@ -178,6 +183,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
178
183
|
})
|
|
179
184
|
|
|
180
185
|
const displayName = exportName ? `${storyId} / ${exportName}` : storyId
|
|
186
|
+
const inactive = !embedActive
|
|
181
187
|
|
|
182
188
|
if (!storyId) {
|
|
183
189
|
return (
|
|
@@ -212,7 +218,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
212
218
|
return (
|
|
213
219
|
<WidgetWrapper>
|
|
214
220
|
<div ref={containerRef} className={styles.container} style={sizeStyle}>
|
|
215
|
-
<div className={styles.header}>
|
|
221
|
+
<div className={`${styles.header}${inactive ? ` ${styles.headerPaused}` : ''}`}>
|
|
216
222
|
<span className={styles.headerIcon}><ComponentIcon size={16} /></span>
|
|
217
223
|
<span className={styles.headerTitle}>{displayName}</span>
|
|
218
224
|
</div>
|
|
@@ -236,6 +242,17 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
236
242
|
<pre className={styles.codeBlock}><code>{sourceCode || ''}</code></pre>
|
|
237
243
|
)}
|
|
238
244
|
</div>
|
|
245
|
+
) : inactive ? (
|
|
246
|
+
<div
|
|
247
|
+
className={overlayStyles.interactOverlay}
|
|
248
|
+
onClick={() => { activateEmbed(); enterInteractive() }}
|
|
249
|
+
role="button"
|
|
250
|
+
tabIndex={0}
|
|
251
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { activateEmbed(); enterInteractive() } }}
|
|
252
|
+
aria-label="Click to refresh"
|
|
253
|
+
>
|
|
254
|
+
<span className={overlayStyles.interactHint}>Click to refresh</span>
|
|
255
|
+
</div>
|
|
239
256
|
) : (
|
|
240
257
|
<>
|
|
241
258
|
<div className={styles.content}>
|
|
@@ -37,6 +37,12 @@
|
|
|
37
37
|
text-overflow: ellipsis;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
.headerPaused {
|
|
41
|
+
background: var(--bgColor-attention-muted, #fff8c5);
|
|
42
|
+
color: var(--fgColor-attention, #9a6700);
|
|
43
|
+
border-bottom-color: var(--borderColor-attention-muted, #d4a72c66);
|
|
44
|
+
}
|
|
45
|
+
|
|
40
46
|
.content {
|
|
41
47
|
position: relative;
|
|
42
48
|
width: 100%;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useRef, useEffect, useCallback, useState } from 'react'
|
|
1
|
+
import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
2
3
|
import { readProp } from './widgetProps.js'
|
|
3
4
|
import { schemas } from './widgetProps.js'
|
|
4
5
|
import { getTerminalConfig } from '@dfosco/storyboard-core'
|
|
@@ -56,6 +57,52 @@ function calcDimensions(widthPx, heightPx) {
|
|
|
56
57
|
return { cols, rows }
|
|
57
58
|
}
|
|
58
59
|
|
|
60
|
+
const EMBED_TYPES = new Set(['prototype', 'story'])
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Find the first connected embed (prototype or story) widget via the canvas bridge.
|
|
64
|
+
*/
|
|
65
|
+
function findConnectedEmbed(widgetId) {
|
|
66
|
+
const bridge = window.__storyboardCanvasBridgeState
|
|
67
|
+
if (!bridge?.connectors || !bridge?.widgets) return null
|
|
68
|
+
const connectedIds = new Set()
|
|
69
|
+
for (const c of bridge.connectors) {
|
|
70
|
+
if (c.startWidgetId === widgetId) connectedIds.add(c.endWidgetId)
|
|
71
|
+
if (c.endWidgetId === widgetId) connectedIds.add(c.startWidgetId)
|
|
72
|
+
}
|
|
73
|
+
for (const w of bridge.widgets) {
|
|
74
|
+
if (connectedIds.has(w.id) && EMBED_TYPES.has(w.type)) return w
|
|
75
|
+
}
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build an iframe URL for a connected embed widget.
|
|
81
|
+
*/
|
|
82
|
+
function buildEmbedUrl(widget) {
|
|
83
|
+
if (!widget) return null
|
|
84
|
+
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
85
|
+
const baseClean = base.endsWith('/') ? base.slice(0, -1) : base
|
|
86
|
+
if (widget.type === 'prototype') {
|
|
87
|
+
const src = widget.props?.src
|
|
88
|
+
if (!src) return null
|
|
89
|
+
if (/^https?:\/\//.test(src)) return src
|
|
90
|
+
return `${baseClean}${src.startsWith('/') ? '' : '/'}${src}?_sb_embed&_sb_hide_branch_bar`
|
|
91
|
+
}
|
|
92
|
+
if (widget.type === 'story') {
|
|
93
|
+
const storyId = widget.props?.storyId
|
|
94
|
+
const exportName = widget.props?.exportName
|
|
95
|
+
if (!storyId) return null
|
|
96
|
+
const storyData = typeof window !== 'undefined' && window.__storyboardStoryIndex?.[storyId]
|
|
97
|
+
if (storyData?._route) {
|
|
98
|
+
const route = exportName ? `${storyData._route}?export=${exportName}` : storyData._route
|
|
99
|
+
return `${baseClean}${route}`
|
|
100
|
+
}
|
|
101
|
+
return null
|
|
102
|
+
}
|
|
103
|
+
return null
|
|
104
|
+
}
|
|
105
|
+
|
|
59
106
|
const DEFAULT_THEME = {
|
|
60
107
|
background: '#0d1117',
|
|
61
108
|
foreground: '#e6edf3',
|
|
@@ -79,7 +126,7 @@ const DEFAULT_THEME = {
|
|
|
79
126
|
brightWhite: '#f0f6fc',
|
|
80
127
|
}
|
|
81
128
|
|
|
82
|
-
export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
129
|
+
export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizable }, ref) {
|
|
83
130
|
const width = readProp(props, 'width', terminalSchema)
|
|
84
131
|
const height = readProp(props, 'height', terminalSchema)
|
|
85
132
|
const prettyName = props?.prettyName || null
|
|
@@ -88,18 +135,59 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
88
135
|
const termRef = useRef(null)
|
|
89
136
|
const terminalRef = useRef(null)
|
|
90
137
|
const wsRef = useRef(null)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
138
|
+
|
|
139
|
+
// State machine: dormant → connecting → live → ended
|
|
140
|
+
// ↘ error
|
|
141
|
+
const [phase, setPhase] = useState('dormant') // dormant | connecting | live | error | ended
|
|
142
|
+
const [errorMsg, setErrorMsg] = useState(null)
|
|
143
|
+
const [interactive, setInteractive] = useState(false)
|
|
94
144
|
const [connectAttempt, setConnectAttempt] = useState(0)
|
|
145
|
+
const [expanded, setExpanded] = useState(false)
|
|
146
|
+
const [waking, setWaking] = useState(false)
|
|
147
|
+
const expandContainerRef = useRef(null)
|
|
148
|
+
|
|
149
|
+
// Activate: transition from dormant to connecting
|
|
150
|
+
const activate = useCallback(() => {
|
|
151
|
+
if (phase === 'dormant') setPhase('connecting')
|
|
152
|
+
}, [phase])
|
|
153
|
+
|
|
154
|
+
const enterInteractive = useCallback(() => {
|
|
155
|
+
if (phase === 'dormant') {
|
|
156
|
+
setPhase('connecting')
|
|
157
|
+
}
|
|
158
|
+
setInteractive(true)
|
|
159
|
+
}, [phase])
|
|
160
|
+
|
|
161
|
+
// Exit interactive on click outside
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (!interactive) return
|
|
164
|
+
function handlePointerDown(e) {
|
|
165
|
+
if (terminalRef.current && !terminalRef.current.contains(e.target)) {
|
|
166
|
+
const chromeEl = e.target.closest(`[data-widget-id="${id}"]`)
|
|
167
|
+
if (chromeEl) return
|
|
168
|
+
setInteractive(false)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
172
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
173
|
+
}, [interactive, id])
|
|
174
|
+
|
|
175
|
+
useImperativeHandle(ref, () => ({
|
|
176
|
+
handleAction(actionId) {
|
|
177
|
+
if (actionId === 'expand') {
|
|
178
|
+
if (phase === 'dormant') setPhase('connecting')
|
|
179
|
+
setExpanded(true)
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
}), [phase])
|
|
95
183
|
|
|
96
184
|
const handleResize = useCallback((w, h) => {
|
|
97
185
|
onUpdate?.({ width: w, height: h })
|
|
98
186
|
}, [onUpdate])
|
|
99
187
|
|
|
100
|
-
//
|
|
188
|
+
// Connect terminal + WebSocket only when phase is 'connecting'
|
|
101
189
|
useEffect(() => {
|
|
102
|
-
if (!containerRef.current) return
|
|
190
|
+
if (phase !== 'connecting' || !containerRef.current) return
|
|
103
191
|
|
|
104
192
|
let disposed = false
|
|
105
193
|
let term = null
|
|
@@ -126,55 +214,46 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
126
214
|
term.open(containerRef.current)
|
|
127
215
|
termRef.current = term
|
|
128
216
|
|
|
129
|
-
// Connect WebSocket
|
|
130
217
|
const url = getWsUrl(id, prettyName)
|
|
131
218
|
ws = new WebSocket(url)
|
|
132
219
|
wsRef.current = ws
|
|
133
220
|
|
|
134
221
|
ws.onopen = () => {
|
|
135
222
|
if (disposed) return
|
|
136
|
-
|
|
223
|
+
setPhase('live')
|
|
137
224
|
ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
|
|
138
225
|
}
|
|
139
226
|
|
|
140
227
|
ws.onmessage = (e) => {
|
|
141
228
|
if (disposed) return
|
|
142
229
|
const data = typeof e.data === 'string' ? e.data : null
|
|
143
|
-
// Intercept JSON control messages from the server
|
|
144
230
|
if (data && data.startsWith('{')) {
|
|
145
231
|
try {
|
|
146
232
|
const msg = JSON.parse(data)
|
|
147
|
-
if (msg.type === 'session-info' || msg.type === 'conflict' || msg.type === 'detached')
|
|
148
|
-
|
|
149
|
-
return
|
|
150
|
-
}
|
|
151
|
-
} catch {
|
|
152
|
-
// Not valid JSON — pass through as terminal data
|
|
153
|
-
}
|
|
233
|
+
if (msg.type === 'session-info' || msg.type === 'conflict' || msg.type === 'detached') return
|
|
234
|
+
} catch { /* not JSON */ }
|
|
154
235
|
}
|
|
155
236
|
term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data))
|
|
156
237
|
}
|
|
157
238
|
|
|
158
239
|
ws.onclose = () => {
|
|
159
240
|
if (disposed) return
|
|
160
|
-
|
|
161
|
-
setSessionEnded(true)
|
|
241
|
+
setPhase('ended')
|
|
162
242
|
}
|
|
163
243
|
|
|
164
244
|
ws.onerror = () => {
|
|
165
245
|
if (disposed) return
|
|
166
|
-
|
|
167
|
-
setSessionEnded(true)
|
|
246
|
+
setPhase('ended')
|
|
168
247
|
}
|
|
169
248
|
|
|
170
|
-
// Terminal input → WebSocket
|
|
171
249
|
term.onData((data) => {
|
|
172
|
-
if (ws.readyState === WebSocket.OPEN)
|
|
173
|
-
ws.send(data)
|
|
174
|
-
}
|
|
250
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(data)
|
|
175
251
|
})
|
|
176
252
|
} catch (err) {
|
|
177
|
-
if (!disposed)
|
|
253
|
+
if (!disposed) {
|
|
254
|
+
setErrorMsg(err.message || 'Failed to load terminal')
|
|
255
|
+
setPhase('error')
|
|
256
|
+
}
|
|
178
257
|
}
|
|
179
258
|
}
|
|
180
259
|
|
|
@@ -187,7 +266,7 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
187
266
|
termRef.current = null
|
|
188
267
|
wsRef.current = null
|
|
189
268
|
}
|
|
190
|
-
}, [id, connectAttempt])
|
|
269
|
+
}, [id, phase === 'connecting', connectAttempt])
|
|
191
270
|
|
|
192
271
|
// Resize terminal on dimension changes
|
|
193
272
|
useEffect(() => {
|
|
@@ -202,19 +281,57 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
202
281
|
return () => clearTimeout(timer)
|
|
203
282
|
}, [width, height])
|
|
204
283
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
termRef.current
|
|
208
|
-
|
|
284
|
+
// Resize terminal to fill the expand container
|
|
285
|
+
useEffect(() => {
|
|
286
|
+
if (!expanded || !termRef.current || !expandContainerRef.current) return
|
|
287
|
+
const timer = setTimeout(() => {
|
|
288
|
+
const el = expandContainerRef.current
|
|
289
|
+
if (!el) return
|
|
290
|
+
const dims = calcDimensions(el.clientWidth, el.clientHeight - 40)
|
|
291
|
+
termRef.current?.resize?.(dims.cols, dims.rows)
|
|
292
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
293
|
+
wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
|
|
294
|
+
}
|
|
295
|
+
}, 100)
|
|
296
|
+
return () => clearTimeout(timer)
|
|
297
|
+
}, [expanded])
|
|
209
298
|
|
|
210
|
-
|
|
299
|
+
// Restore terminal size when collapsing
|
|
300
|
+
useEffect(() => {
|
|
301
|
+
if (expanded) return
|
|
302
|
+
if (!termRef.current) return
|
|
303
|
+
const timer = setTimeout(() => {
|
|
304
|
+
const dims = calcDimensions(width, height)
|
|
305
|
+
termRef.current?.resize?.(dims.cols, dims.rows)
|
|
306
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
307
|
+
wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
|
|
308
|
+
}
|
|
309
|
+
}, 100)
|
|
310
|
+
return () => clearTimeout(timer)
|
|
311
|
+
}, [expanded, width, height])
|
|
312
|
+
|
|
313
|
+
// Reparent terminal DOM node between inline and expand
|
|
314
|
+
useEffect(() => {
|
|
315
|
+
const xtermEl = containerRef.current
|
|
316
|
+
if (!xtermEl) return
|
|
317
|
+
if (expanded && expandContainerRef.current) {
|
|
318
|
+
expandContainerRef.current.appendChild(xtermEl)
|
|
319
|
+
} else if (!expanded && terminalRef.current) {
|
|
320
|
+
terminalRef.current.appendChild(xtermEl)
|
|
321
|
+
}
|
|
322
|
+
}, [expanded])
|
|
323
|
+
|
|
324
|
+
const handleClick = useCallback(() => {
|
|
325
|
+
if (phase === 'ended') return
|
|
326
|
+
if (phase === 'live') termRef.current?.focus()
|
|
327
|
+
}, [phase])
|
|
211
328
|
|
|
212
329
|
const handleStartSession = useCallback(() => {
|
|
213
330
|
setWaking(true)
|
|
214
331
|
setTimeout(() => {
|
|
215
332
|
setWaking(false)
|
|
216
|
-
|
|
217
|
-
|
|
333
|
+
setErrorMsg(null)
|
|
334
|
+
setPhase('connecting')
|
|
218
335
|
setConnectAttempt(c => c + 1)
|
|
219
336
|
}, 1500)
|
|
220
337
|
}, [])
|
|
@@ -222,8 +339,13 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
222
339
|
// Show interact gate when session is ready but not interacting
|
|
223
340
|
|
|
224
341
|
const titleLabel = `terminal · ${prettyName || '...'}`
|
|
342
|
+
const connectedEmbed = expanded ? findConnectedEmbed(id) : null
|
|
343
|
+
const embedUrl = expanded ? buildEmbedUrl(connectedEmbed) : null
|
|
344
|
+
const hasSplit = Boolean(embedUrl)
|
|
345
|
+
const isDormant = phase === 'dormant'
|
|
225
346
|
|
|
226
347
|
return (
|
|
348
|
+
<>
|
|
227
349
|
<div className={styles.container}>
|
|
228
350
|
<div className={styles.titleBar}>{titleLabel}</div>
|
|
229
351
|
<div
|
|
@@ -235,13 +357,51 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
235
357
|
}}
|
|
236
358
|
onClick={handleClick}
|
|
237
359
|
>
|
|
238
|
-
{
|
|
360
|
+
{phase === 'error' && (
|
|
239
361
|
<div className={styles.error}>
|
|
240
|
-
<span>⚠ {
|
|
362
|
+
<span>⚠ {errorMsg}</span>
|
|
363
|
+
</div>
|
|
364
|
+
)}
|
|
365
|
+
{!expanded && <div ref={containerRef} className={styles.xtermContainer} />}
|
|
366
|
+
|
|
367
|
+
{/* Dormant: not yet activated */}
|
|
368
|
+
{isDormant && (
|
|
369
|
+
<div
|
|
370
|
+
className={overlayStyles.interactOverlay}
|
|
371
|
+
style={{ backgroundColor: '#0d1117' }}
|
|
372
|
+
onClick={(e) => {
|
|
373
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
374
|
+
enterInteractive()
|
|
375
|
+
}}
|
|
376
|
+
role="button"
|
|
377
|
+
tabIndex={0}
|
|
378
|
+
onKeyDown={(e) => { if (e.key === 'Enter') enterInteractive() }}
|
|
379
|
+
aria-label="Click to interact"
|
|
380
|
+
>
|
|
381
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
382
|
+
</div>
|
|
383
|
+
)}
|
|
384
|
+
|
|
385
|
+
{/* Live but not interactive: gated overlay */}
|
|
386
|
+
{phase === 'live' && !interactive && (
|
|
387
|
+
<div
|
|
388
|
+
className={overlayStyles.interactOverlay}
|
|
389
|
+
style={{ backgroundColor: 'transparent' }}
|
|
390
|
+
onClick={(e) => {
|
|
391
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
392
|
+
enterInteractive()
|
|
393
|
+
}}
|
|
394
|
+
role="button"
|
|
395
|
+
tabIndex={0}
|
|
396
|
+
onKeyDown={(e) => { if (e.key === 'Enter') enterInteractive() }}
|
|
397
|
+
aria-label="Click to interact"
|
|
398
|
+
>
|
|
399
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
241
400
|
</div>
|
|
242
401
|
)}
|
|
243
|
-
|
|
244
|
-
{
|
|
402
|
+
|
|
403
|
+
{/* Session ended */}
|
|
404
|
+
{phase === 'ended' && (
|
|
245
405
|
<div
|
|
246
406
|
className={overlayStyles.interactOverlay}
|
|
247
407
|
style={{ backgroundColor: '#0d1117', flexDirection: 'column', gap: 0 }}
|
|
@@ -263,7 +423,9 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
263
423
|
</span>
|
|
264
424
|
</div>
|
|
265
425
|
)}
|
|
266
|
-
|
|
426
|
+
|
|
427
|
+
{/* Connecting */}
|
|
428
|
+
{phase === 'connecting' && (
|
|
267
429
|
<div className={styles.loading}>Connecting…</div>
|
|
268
430
|
)}
|
|
269
431
|
</div>
|
|
@@ -276,5 +438,34 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
276
438
|
/>
|
|
277
439
|
)}
|
|
278
440
|
</div>
|
|
441
|
+
{createPortal(
|
|
442
|
+
<div
|
|
443
|
+
className={styles.expandBackdrop}
|
|
444
|
+
style={expanded ? undefined : { display: 'none' }}
|
|
445
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
446
|
+
onKeyDown={(e) => { if (e.key === 'Escape') setExpanded(false) }}
|
|
447
|
+
onWheel={(e) => e.stopPropagation()}
|
|
448
|
+
>
|
|
449
|
+
<div className={styles.expandTopBar}>
|
|
450
|
+
<span className={styles.expandTitle}>{titleLabel}</span>
|
|
451
|
+
{hasSplit && connectedEmbed && (
|
|
452
|
+
<span className={styles.expandEmbedLabel}>
|
|
453
|
+
{connectedEmbed.type === 'story' ? connectedEmbed.props?.storyId : connectedEmbed.props?.src || 'Prototype'}
|
|
454
|
+
</span>
|
|
455
|
+
)}
|
|
456
|
+
<button className={styles.expandClose} onClick={() => setExpanded(false)} aria-label="Close expanded view" autoFocus>✕</button>
|
|
457
|
+
</div>
|
|
458
|
+
<div className={`${styles.expandBody}${hasSplit ? ` ${styles.expandSplit}` : ''}`}>
|
|
459
|
+
<div ref={expandContainerRef} className={styles.expandTerminal} />
|
|
460
|
+
{hasSplit && (
|
|
461
|
+
<div className={styles.expandEmbed}>
|
|
462
|
+
<iframe src={embedUrl} className={styles.expandIframe} title="Connected embed" />
|
|
463
|
+
</div>
|
|
464
|
+
)}
|
|
465
|
+
</div>
|
|
466
|
+
</div>,
|
|
467
|
+
document.body
|
|
468
|
+
)}
|
|
469
|
+
</>
|
|
279
470
|
)
|
|
280
|
-
}
|
|
471
|
+
})
|
|
@@ -156,3 +156,108 @@
|
|
|
156
156
|
transform: translateY(-24px);
|
|
157
157
|
}
|
|
158
158
|
}
|
|
159
|
+
|
|
160
|
+
/* Fullscreen expand */
|
|
161
|
+
.expandBackdrop {
|
|
162
|
+
position: fixed;
|
|
163
|
+
inset: 0;
|
|
164
|
+
z-index: 100000;
|
|
165
|
+
background: #0d1117;
|
|
166
|
+
display: flex;
|
|
167
|
+
flex-direction: column;
|
|
168
|
+
animation: expandFadeIn 0.15s ease;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
@keyframes expandFadeIn {
|
|
172
|
+
from { opacity: 0; }
|
|
173
|
+
to { opacity: 1; }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
.expandTopBar {
|
|
177
|
+
display: flex;
|
|
178
|
+
align-items: center;
|
|
179
|
+
height: 40px;
|
|
180
|
+
padding: 0 12px;
|
|
181
|
+
background: #161b22;
|
|
182
|
+
border-bottom: 1px solid #30363d;
|
|
183
|
+
flex-shrink: 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
.expandTitle {
|
|
187
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
188
|
+
font-size: 12px;
|
|
189
|
+
font-weight: 500;
|
|
190
|
+
color: #e6edf3;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.expandEmbedLabel {
|
|
194
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
195
|
+
font-size: 12px;
|
|
196
|
+
color: #8b949e;
|
|
197
|
+
margin-left: auto;
|
|
198
|
+
margin-right: 12px;
|
|
199
|
+
overflow: hidden;
|
|
200
|
+
text-overflow: ellipsis;
|
|
201
|
+
white-space: nowrap;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
.expandClose {
|
|
205
|
+
all: unset;
|
|
206
|
+
cursor: pointer;
|
|
207
|
+
margin-left: auto;
|
|
208
|
+
width: 28px;
|
|
209
|
+
height: 28px;
|
|
210
|
+
display: flex;
|
|
211
|
+
align-items: center;
|
|
212
|
+
justify-content: center;
|
|
213
|
+
border-radius: 6px;
|
|
214
|
+
color: #8b949e;
|
|
215
|
+
font-size: 14px;
|
|
216
|
+
transition: background 100ms, color 100ms;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.expandClose:hover {
|
|
220
|
+
background: #30363d;
|
|
221
|
+
color: #e6edf3;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
.expandEmbedLabel + .expandClose {
|
|
225
|
+
margin-left: 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.expandBody {
|
|
229
|
+
flex: 1;
|
|
230
|
+
min-height: 0;
|
|
231
|
+
display: flex;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
.expandTerminal {
|
|
235
|
+
flex: 1;
|
|
236
|
+
min-width: 0;
|
|
237
|
+
overflow: hidden;
|
|
238
|
+
background: #0d1117;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
.expandTerminal :global(.xterm) {
|
|
242
|
+
height: 100%;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.expandSplit .expandTerminal {
|
|
246
|
+
flex: 1;
|
|
247
|
+
border-right: 1px solid #30363d;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.expandSplit .expandEmbed {
|
|
251
|
+
flex: 1;
|
|
252
|
+
min-width: 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
.expandEmbed {
|
|
256
|
+
display: flex;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.expandIframe {
|
|
260
|
+
border: none;
|
|
261
|
+
width: 100%;
|
|
262
|
+
height: 100%;
|
|
263
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
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
|
+
}
|