@dfosco/storyboard-react 4.0.0-beta.21 → 4.0.0-beta.23
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
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "4.0.0-beta.
|
|
3
|
+
"version": "4.0.0-beta.23",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "4.0.0-beta.
|
|
7
|
-
"@dfosco/tiny-canvas": "4.0.0-beta.
|
|
6
|
+
"@dfosco/storyboard-core": "4.0.0-beta.23",
|
|
7
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.23",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1",
|
|
@@ -344,6 +344,7 @@ export default function CanvasPage({ name, siblingPages = [], canvasMeta = null
|
|
|
344
344
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
345
345
|
const [snapEnabled, setSnapEnabled] = useState(canvas?.snapToGrid ?? false)
|
|
346
346
|
const [snapGridSize, setSnapGridSize] = useState(canvas?.gridSize || 40)
|
|
347
|
+
|
|
347
348
|
// Refs for snap settings (used by drop handler inside effect closure)
|
|
348
349
|
const snapEnabledRef = useRef(snapEnabled)
|
|
349
350
|
const snapGridSizeRef = useRef(snapGridSize)
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
import { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
|
-
import { buildPrototypeIndex } from '@dfosco/storyboard-core'
|
|
3
|
+
import { buildPrototypeIndex, getFlag } from '@dfosco/storyboard-core'
|
|
4
4
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
5
5
|
import { readProp, prototypeEmbedSchema } from './widgetProps.js'
|
|
6
6
|
import { getEmbedChromeVars } from './embedTheme.js'
|
|
7
7
|
import { uploadImage } from '../canvasApi.js'
|
|
8
|
+
import { useIframeQueue } from './useViewportEntry.js'
|
|
8
9
|
import styles from './PrototypeEmbed.module.css'
|
|
9
10
|
import overlayStyles from './embedOverlay.module.css'
|
|
10
11
|
|
|
12
|
+
function devLog(...args) {
|
|
13
|
+
try { if (getFlag('dev-logs')) console.log('[canvas:prototype-embed]', ...args) } catch { /* */ }
|
|
14
|
+
}
|
|
15
|
+
|
|
11
16
|
function formatName(name) {
|
|
12
17
|
return name
|
|
13
18
|
.replace(/[-_]/g, ' ')
|
|
@@ -68,12 +73,43 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
68
73
|
const validSnapshotDark = snapshotMatchesWidget(snapshotDark) ? snapshotDark : null
|
|
69
74
|
const currentSnapshot = canvasTheme?.startsWith('dark') ? validSnapshotDark : validSnapshotLight
|
|
70
75
|
const hasSnapshot = !!currentSnapshot
|
|
71
|
-
|
|
76
|
+
|
|
77
|
+
// Sequential iframe queue — prevents stampede when many embeds lack snapshots.
|
|
78
|
+
// Widgets with snapshots skip the queue entirely; others load one at a time.
|
|
79
|
+
const { ready: queueReady, releaseSlot } = useIframeQueue(hasSnapshot || isExternal, widgetId)
|
|
80
|
+
const [preloadIframe, setPreloadIframe] = useState(hasSnapshot || isExternal)
|
|
72
81
|
const [iframeLoaded, setIframeLoaded] = useState(false)
|
|
73
|
-
const [showIframe, setShowIframe] = useState(
|
|
82
|
+
const [showIframe, setShowIframe] = useState(hasSnapshot || isExternal)
|
|
74
83
|
const [showSpinner, setShowSpinner] = useState(false)
|
|
75
84
|
const capturingRef = useRef(false)
|
|
76
85
|
|
|
86
|
+
devLog(widgetId, { hasSnapshot, isExternal, queueReady, preloadIframe, showIframe, iframeLoaded, src })
|
|
87
|
+
|
|
88
|
+
// Start loading when the queue grants this widget a slot
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (queueReady && !preloadIframe) {
|
|
91
|
+
devLog(widgetId, 'queue ready → loading iframe')
|
|
92
|
+
setPreloadIframe(true)
|
|
93
|
+
setShowIframe(true)
|
|
94
|
+
}
|
|
95
|
+
}, [queueReady, preloadIframe])
|
|
96
|
+
|
|
97
|
+
// Release the queue slot once the iframe has loaded or user clicked to interact
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (iframeLoaded) {
|
|
100
|
+
devLog(widgetId, 'iframe loaded')
|
|
101
|
+
releaseSlot()
|
|
102
|
+
}
|
|
103
|
+
}, [iframeLoaded, releaseSlot])
|
|
104
|
+
|
|
105
|
+
// Click-to-interact: immediately start iframe and release queue slot for others
|
|
106
|
+
const activateIframe = useCallback(() => {
|
|
107
|
+
devLog(widgetId, 'user activated → jumping queue')
|
|
108
|
+
setShowIframe(true)
|
|
109
|
+
setPreloadIframe(true)
|
|
110
|
+
releaseSlot()
|
|
111
|
+
}, [releaseSlot])
|
|
112
|
+
|
|
77
113
|
// Show spinner only after 500ms of loading
|
|
78
114
|
useEffect(() => {
|
|
79
115
|
if (showIframe && !iframeLoaded && hasSnapshot) {
|
|
@@ -535,8 +571,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
535
571
|
}}
|
|
536
572
|
onClick={(e) => {
|
|
537
573
|
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
538
|
-
|
|
539
|
-
setPreloadIframe(true)
|
|
574
|
+
activateIframe()
|
|
540
575
|
enterInteractive()
|
|
541
576
|
}}
|
|
542
577
|
role="button"
|
|
@@ -545,8 +580,7 @@ export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdat
|
|
|
545
580
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
546
581
|
e.preventDefault()
|
|
547
582
|
e.stopPropagation()
|
|
548
|
-
|
|
549
|
-
setPreloadIframe(true)
|
|
583
|
+
activateIframe()
|
|
550
584
|
enterInteractive()
|
|
551
585
|
}
|
|
552
586
|
}}
|
|
@@ -12,14 +12,19 @@
|
|
|
12
12
|
* Props: { storyId, exportName, width, height }
|
|
13
13
|
*/
|
|
14
14
|
import { forwardRef, useImperativeHandle, useRef, useCallback, useState, useEffect, useMemo } from 'react'
|
|
15
|
-
import { getStoryData } from '@dfosco/storyboard-core'
|
|
15
|
+
import { getStoryData, getFlag } from '@dfosco/storyboard-core'
|
|
16
16
|
import { createInspectorHighlighter } from '@dfosco/storyboard-core/inspector/highlighter'
|
|
17
17
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
18
18
|
import ResizeHandle from './ResizeHandle.jsx'
|
|
19
19
|
import { uploadImage } from '../canvasApi.js'
|
|
20
|
+
import { useIframeQueue } from './useViewportEntry.js'
|
|
20
21
|
import styles from './StoryWidget.module.css'
|
|
21
22
|
import overlayStyles from './embedOverlay.module.css'
|
|
22
23
|
|
|
24
|
+
function devLog(...args) {
|
|
25
|
+
try { if (getFlag('dev-logs')) console.log('[canvas:story-widget]', ...args) } catch { /* */ }
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
function resolveStoryUrl(storyId, exportName) {
|
|
24
29
|
const story = getStoryData(storyId)
|
|
25
30
|
if (!story?._route) return null
|
|
@@ -111,12 +116,42 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
111
116
|
const validSnapshotDark = snapshotMatchesWidget(snapshotDark) ? snapshotDark : null
|
|
112
117
|
const currentSnapshot = isDark ? validSnapshotDark : validSnapshotLight
|
|
113
118
|
const hasSnapshot = !!currentSnapshot
|
|
114
|
-
|
|
119
|
+
|
|
120
|
+
// Sequential iframe queue — prevents stampede when many embeds lack snapshots.
|
|
121
|
+
const { ready: queueReady, releaseSlot } = useIframeQueue(hasSnapshot, widgetId)
|
|
122
|
+
const [preloadIframe, setPreloadIframe] = useState(hasSnapshot)
|
|
115
123
|
const [iframeLoaded, setIframeLoaded] = useState(false)
|
|
116
|
-
const [showIframe, setShowIframe] = useState(
|
|
124
|
+
const [showIframe, setShowIframe] = useState(hasSnapshot)
|
|
117
125
|
const [showSpinner, setShowSpinner] = useState(false)
|
|
118
126
|
const capturingRef = useRef(false)
|
|
119
127
|
|
|
128
|
+
devLog(widgetId, { hasSnapshot, queueReady, preloadIframe, showIframe, iframeLoaded, storyId })
|
|
129
|
+
|
|
130
|
+
// Start loading when the queue grants this widget a slot
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (queueReady && !preloadIframe) {
|
|
133
|
+
devLog(widgetId, 'queue ready → loading iframe')
|
|
134
|
+
setPreloadIframe(true)
|
|
135
|
+
setShowIframe(true)
|
|
136
|
+
}
|
|
137
|
+
}, [queueReady, preloadIframe])
|
|
138
|
+
|
|
139
|
+
// Release the queue slot once the iframe has loaded or user clicked to interact
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (iframeLoaded) {
|
|
142
|
+
devLog(widgetId, 'iframe loaded')
|
|
143
|
+
releaseSlot()
|
|
144
|
+
}
|
|
145
|
+
}, [iframeLoaded, releaseSlot])
|
|
146
|
+
|
|
147
|
+
// Click-to-interact: immediately start iframe and release queue slot for others
|
|
148
|
+
const activateIframe = useCallback(() => {
|
|
149
|
+
devLog(widgetId, 'user activated → jumping queue')
|
|
150
|
+
setShowIframe(true)
|
|
151
|
+
setPreloadIframe(true)
|
|
152
|
+
releaseSlot()
|
|
153
|
+
}, [releaseSlot, widgetId])
|
|
154
|
+
|
|
120
155
|
// Show spinner only after 500ms of loading
|
|
121
156
|
useEffect(() => {
|
|
122
157
|
if (showIframe && !iframeLoaded && hasSnapshot) {
|
|
@@ -435,8 +470,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
435
470
|
}}
|
|
436
471
|
onClick={(e) => {
|
|
437
472
|
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
438
|
-
|
|
439
|
-
setPreloadIframe(true)
|
|
473
|
+
activateIframe()
|
|
440
474
|
enterInteractive()
|
|
441
475
|
}}
|
|
442
476
|
role="button"
|
|
@@ -445,8 +479,7 @@ export default forwardRef(function StoryWidget({ id: widgetId, props, onUpdate,
|
|
|
445
479
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
446
480
|
e.preventDefault()
|
|
447
481
|
e.stopPropagation()
|
|
448
|
-
|
|
449
|
-
setPreloadIframe(true)
|
|
482
|
+
activateIframe()
|
|
450
483
|
enterInteractive()
|
|
451
484
|
}
|
|
452
485
|
}}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
2
|
+
import { getFlag } from '@dfosco/storyboard-core'
|
|
3
|
+
|
|
4
|
+
function devLog(...args) {
|
|
5
|
+
try { if (getFlag('dev-logs')) console.log('[canvas:iframe-queue]', ...args) } catch { /* flag system not initialized */ }
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sequential iframe loading queue.
|
|
10
|
+
*
|
|
11
|
+
* Embed widgets (prototype, story) that lack snapshots call `requestSlot()`
|
|
12
|
+
* which returns a promise that resolves when it's their turn to load.
|
|
13
|
+
* Only one iframe loads at a time — the next starts when the previous
|
|
14
|
+
* calls the returned `release()` function (or on timeout).
|
|
15
|
+
*
|
|
16
|
+
* This prevents the "iframe stampede" when many embeds lack snapshots
|
|
17
|
+
* and would otherwise all try to load simultaneously.
|
|
18
|
+
*/
|
|
19
|
+
const SLOT_TIMEOUT = 15_000
|
|
20
|
+
|
|
21
|
+
let _queue = []
|
|
22
|
+
let _active = false
|
|
23
|
+
|
|
24
|
+
function processQueue() {
|
|
25
|
+
if (_active || _queue.length === 0) return
|
|
26
|
+
_active = true
|
|
27
|
+
const { resolve, label } = _queue.shift()
|
|
28
|
+
devLog(`slot granted → ${label} (${_queue.length} queued)`)
|
|
29
|
+
|
|
30
|
+
let released = false
|
|
31
|
+
const release = () => {
|
|
32
|
+
if (released) return
|
|
33
|
+
released = true
|
|
34
|
+
_active = false
|
|
35
|
+
devLog(`slot released ← ${label}`)
|
|
36
|
+
processQueue()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Safety timeout — if the iframe never signals load, release after 15s
|
|
40
|
+
setTimeout(release, SLOT_TIMEOUT)
|
|
41
|
+
resolve(release)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function requestSlot(label = '?') {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
_queue.push({ resolve, label })
|
|
47
|
+
devLog(`queued ${label} (position ${_queue.length})`)
|
|
48
|
+
processQueue()
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Hook for embed widgets that need sequential iframe loading.
|
|
54
|
+
*
|
|
55
|
+
* When the widget has a usable snapshot, this is a no-op (returns ready immediately).
|
|
56
|
+
* When no snapshot exists, it queues the widget and returns `ready: true` when
|
|
57
|
+
* it's this widget's turn.
|
|
58
|
+
*
|
|
59
|
+
* @param {boolean} hasUsableSnapshot - whether the widget has a working snapshot
|
|
60
|
+
* @param {string} [label] - debug label for dev logs
|
|
61
|
+
* @returns {{ ready: boolean, releaseSlot: Function }}
|
|
62
|
+
*/
|
|
63
|
+
export function useIframeQueue(hasUsableSnapshot, label = '?') {
|
|
64
|
+
const [ready, setReady] = useState(hasUsableSnapshot)
|
|
65
|
+
const releaseRef = useRef(null)
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (hasUsableSnapshot || ready) return
|
|
69
|
+
|
|
70
|
+
let cancelled = false
|
|
71
|
+
requestSlot(label).then((release) => {
|
|
72
|
+
if (cancelled) {
|
|
73
|
+
release()
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
releaseRef.current = release
|
|
77
|
+
setReady(true)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
cancelled = true
|
|
82
|
+
releaseRef.current?.()
|
|
83
|
+
}
|
|
84
|
+
}, [hasUsableSnapshot]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
85
|
+
|
|
86
|
+
// Release the slot when the component unmounts
|
|
87
|
+
const releaseSlot = useCallback(() => {
|
|
88
|
+
releaseRef.current?.()
|
|
89
|
+
releaseRef.current = null
|
|
90
|
+
}, [])
|
|
91
|
+
|
|
92
|
+
return { ready, releaseSlot }
|
|
93
|
+
}
|