@dfosco/storyboard-react 4.0.0-beta.3 → 4.0.0-beta.30
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 +7 -4
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +95 -10
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +790 -302
- package/src/canvas/CanvasPage.module.css +70 -47
- package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/ComponentErrorBoundary.jsx +50 -0
- package/src/canvas/PageSelector.jsx +102 -0
- package/src/canvas/PageSelector.module.css +93 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +22 -8
- package/src/canvas/canvasReloadGuard.js +37 -0
- package/src/canvas/canvasReloadGuard.test.js +27 -0
- package/src/canvas/componentIsolate.jsx +135 -0
- package/src/canvas/useCanvas.js +15 -10
- package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/ComponentWidget.jsx +82 -9
- package/src/canvas/widgets/ComponentWidget.module.css +14 -6
- package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
- package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
- package/src/canvas/widgets/LinkPreview.jsx +247 -18
- package/src/canvas/widgets/LinkPreview.module.css +349 -8
- package/src/canvas/widgets/LinkPreview.test.jsx +71 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +95 -21
- package/src/canvas/widgets/MarkdownBlock.module.css +133 -2
- package/src/canvas/widgets/MarkdownBlock.test.jsx +39 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +319 -70
- package/src/canvas/widgets/PrototypeEmbed.module.css +74 -4
- package/src/canvas/widgets/StickyNote.module.css +5 -0
- package/src/canvas/widgets/StickyNote.test.jsx +9 -9
- package/src/canvas/widgets/StoryWidget.jsx +512 -0
- package/src/canvas/widgets/StoryWidget.module.css +211 -0
- package/src/canvas/widgets/WidgetChrome.jsx +76 -20
- package/src/canvas/widgets/WidgetChrome.module.css +4 -7
- package/src/canvas/widgets/WidgetWrapper.module.css +2 -0
- package/src/canvas/widgets/codepenUrl.js +75 -0
- package/src/canvas/widgets/codepenUrl.test.js +76 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +235 -0
- package/src/canvas/widgets/embedOverlay.module.css +35 -0
- package/src/canvas/widgets/embedTheme.js +56 -0
- package/src/canvas/widgets/githubUrl.js +82 -0
- package/src/canvas/widgets/githubUrl.test.js +74 -0
- package/src/canvas/widgets/iframeDevLogs.js +49 -0
- package/src/canvas/widgets/iframeDevLogs.test.jsx +81 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/pasteRules.js +295 -0
- package/src/canvas/widgets/pasteRules.test.js +474 -0
- package/src/canvas/widgets/refreshQueue.js +108 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +259 -0
- package/src/canvas/widgets/useSnapshotCapture.js +157 -0
- package/src/canvas/widgets/useSnapshotCapture.test.jsx +164 -0
- package/src/canvas/widgets/widgetConfig.js +16 -5
- package/src/canvas/widgets/widgetConfig.test.js +34 -12
- package/src/context.jsx +141 -16
- package/src/hooks/useSceneData.js +4 -2
- package/src/story/StoryPage.jsx +117 -0
- package/src/story/StoryPage.module.css +18 -0
- package/src/vite/data-plugin.js +458 -71
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -3,8 +3,22 @@ import { createPortal } from 'react-dom'
|
|
|
3
3
|
import { buildPrototypeIndex } from '@dfosco/storyboard-core'
|
|
4
4
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
5
5
|
import { readProp, prototypeEmbedSchema } from './widgetProps.js'
|
|
6
|
-
import { getEmbedChromeVars } from './embedTheme.js'
|
|
6
|
+
import { getEmbedChromeVars, subscribeCanvasTheme } from './embedTheme.js'
|
|
7
|
+
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
8
|
+
import { useSnapshotCapture } from './useSnapshotCapture.js'
|
|
9
|
+
import { enqueueRefresh, cancelRefresh, REVEAL_INTERVAL } from './refreshQueue.js'
|
|
7
10
|
import styles from './PrototypeEmbed.module.css'
|
|
11
|
+
import overlayStyles from './embedOverlay.module.css'
|
|
12
|
+
|
|
13
|
+
function CollageFrameIcon({ size = 36 }) {
|
|
14
|
+
return (
|
|
15
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
|
16
|
+
<path d="M19.4 20H4.6C4.26863 20 4 19.7314 4 19.4V4.6C4 4.26863 4.26863 4 4.6 4H19.4C19.7314 4 20 4.26863 20 4.6V19.4C20 19.7314 19.7314 20 19.4 20Z" />
|
|
17
|
+
<path d="M11 12V4" />
|
|
18
|
+
<path d="M4 12H20" />
|
|
19
|
+
</svg>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
8
22
|
|
|
9
23
|
function formatName(name) {
|
|
10
24
|
return name
|
|
@@ -12,36 +26,54 @@ function formatName(name) {
|
|
|
12
26
|
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
13
27
|
}
|
|
14
28
|
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
29
|
+
function listInternalPrototypes(index) {
|
|
30
|
+
const allProtos = []
|
|
31
|
+
const sortedFolders = index.sorted?.title?.folders
|
|
32
|
+
const sortedPrototypes = index.sorted?.title?.prototypes
|
|
33
|
+
const folderList = Array.isArray(sortedFolders) && sortedFolders.length > 0
|
|
34
|
+
? sortedFolders
|
|
35
|
+
: (index.folders || [])
|
|
36
|
+
const standaloneList = Array.isArray(sortedPrototypes) && sortedPrototypes.length > 0
|
|
37
|
+
? sortedPrototypes
|
|
38
|
+
: (index.prototypes || [])
|
|
39
|
+
|
|
40
|
+
for (const folder of folderList) {
|
|
41
|
+
for (const proto of folder.prototypes || []) {
|
|
42
|
+
if (!proto.isExternal) allProtos.push(proto)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const proto of standaloneList) {
|
|
46
|
+
if (!proto.isExternal) allProtos.push(proto)
|
|
47
|
+
}
|
|
48
|
+
return allProtos
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeRoutePath(value, basePath = '') {
|
|
52
|
+
if (!value || /^https?:\/\//.test(value)) return ''
|
|
53
|
+
const noHash = value.split('#')[0]
|
|
54
|
+
let route = noHash.split('?')[0]
|
|
55
|
+
route = route.replace(/^\/branch--[^/]+/, '')
|
|
56
|
+
if (basePath && route.startsWith(basePath)) {
|
|
57
|
+
route = route.slice(basePath.length) || '/'
|
|
23
58
|
}
|
|
24
|
-
if (!
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const stored = localStorage.getItem('sb-color-scheme') || 'system'
|
|
28
|
-
if (stored !== 'system') return stored
|
|
29
|
-
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
59
|
+
if (!route.startsWith('/')) route = `/${route}`
|
|
60
|
+
route = route.replace(/\/+$/, '')
|
|
61
|
+
return route || '/'
|
|
30
62
|
}
|
|
31
63
|
|
|
32
|
-
export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }, ref) {
|
|
64
|
+
export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
|
|
33
65
|
const src = readProp(props, 'src', prototypeEmbedSchema)
|
|
34
66
|
const width = readProp(props, 'width', prototypeEmbedSchema)
|
|
35
67
|
const height = readProp(props, 'height', prototypeEmbedSchema)
|
|
36
68
|
const zoom = readProp(props, 'zoom', prototypeEmbedSchema)
|
|
37
69
|
const label = readProp(props, 'label', prototypeEmbedSchema) || src
|
|
70
|
+
const snapshot = props?.snapshot || props?.snapshotLight || props?.snapshotDark || ''
|
|
38
71
|
|
|
39
72
|
const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
40
73
|
const baseSegment = basePath.replace(/^\//, '')
|
|
41
74
|
const rawSrc = useMemo(() => {
|
|
42
75
|
if (!src) return ''
|
|
43
76
|
if (/^https?:\/\//.test(src)) return src
|
|
44
|
-
// Strip stale branch prefixes from stored src (e.g. /branch--old-feat/Page)
|
|
45
77
|
const cleaned = src.replace(/^\/branch--[^/]+/, '')
|
|
46
78
|
if (baseSegment && cleaned.startsWith(basePath)) return cleaned
|
|
47
79
|
if (baseSegment && cleaned.startsWith(baseSegment)) return `/${cleaned}`
|
|
@@ -52,15 +84,36 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
52
84
|
|
|
53
85
|
const [editing, setEditing] = useState(false)
|
|
54
86
|
const [interactive, setInteractive] = useState(false)
|
|
87
|
+
const [showIframe, setShowIframe] = useState(false)
|
|
88
|
+
const [iframeLoaded, setIframeLoaded] = useState(false)
|
|
55
89
|
const [expanded, setExpanded] = useState(false)
|
|
56
90
|
const [filter, setFilter] = useState('')
|
|
57
|
-
const [canvasTheme, setCanvasTheme] = useState(
|
|
91
|
+
const [canvasTheme, setCanvasTheme] = useState('light')
|
|
92
|
+
const [brokenSnaps, setBrokenSnaps] = useState({})
|
|
93
|
+
|
|
58
94
|
const inputRef = useRef(null)
|
|
59
95
|
const filterRef = useRef(null)
|
|
60
96
|
const embedRef = useRef(null)
|
|
61
97
|
const iframeRef = useRef(null)
|
|
98
|
+
const captureOnReadyRef = useRef(false)
|
|
99
|
+
const exitSessionRef = useRef(0)
|
|
100
|
+
const teardownTimerRef = useRef(null)
|
|
62
101
|
const inlineContainerRef = useRef(null)
|
|
63
102
|
const modalContainerRef = useRef(null)
|
|
103
|
+
const resizeTimerRef = useRef(null)
|
|
104
|
+
const prevInteractiveRef = useRef(false)
|
|
105
|
+
|
|
106
|
+
// Snapshot capture hook — only active in dev mode (onUpdate present)
|
|
107
|
+
const isExternal = /^https?:\/\//.test(src || '')
|
|
108
|
+
const { iframeReady, requestCapture } = useSnapshotCapture({
|
|
109
|
+
iframeRef,
|
|
110
|
+
widgetId,
|
|
111
|
+
onUpdate: isExternal ? null : onUpdate,
|
|
112
|
+
showIframe,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// Single snapshot — backward compat reads snapshotLight/snapshotDark if snapshot is missing
|
|
116
|
+
const hasSnap = !isExternal && !!(snapshot && snapshot.includes(widgetId) && !brokenSnaps[snapshot])
|
|
64
117
|
|
|
65
118
|
const iframeSrc = useMemo(() => {
|
|
66
119
|
if (!rawSrc) return ''
|
|
@@ -70,8 +123,14 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
70
123
|
const base = hashIdx >= 0 ? rawSrc.slice(0, hashIdx) : rawSrc
|
|
71
124
|
const hash = hashIdx >= 0 ? rawSrc.slice(hashIdx) : ''
|
|
72
125
|
const sep = base.includes('?') ? '&' : '?'
|
|
73
|
-
return `${base}${sep}_sb_embed&_sb_theme_target=prototype
|
|
74
|
-
}, [rawSrc
|
|
126
|
+
return `${base}${sep}_sb_embed&_sb_theme_target=prototype${hash}`
|
|
127
|
+
}, [rawSrc])
|
|
128
|
+
|
|
129
|
+
useIframeDevLogs({
|
|
130
|
+
widget: 'PrototypeEmbed',
|
|
131
|
+
loaded: showIframe && Boolean(iframeSrc),
|
|
132
|
+
src: iframeSrc,
|
|
133
|
+
})
|
|
75
134
|
|
|
76
135
|
// Build prototype index for the picker
|
|
77
136
|
const prototypeIndex = useMemo(() => {
|
|
@@ -87,16 +146,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
87
146
|
const groups = []
|
|
88
147
|
const idx = prototypeIndex
|
|
89
148
|
|
|
90
|
-
|
|
91
|
-
const allProtos = []
|
|
92
|
-
for (const folder of (idx.sorted?.title?.folders || idx.folders || [])) {
|
|
93
|
-
for (const proto of folder.prototypes || []) {
|
|
94
|
-
if (!proto.isExternal) allProtos.push(proto)
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
for (const proto of (idx.sorted?.title?.prototypes || idx.prototypes || [])) {
|
|
98
|
-
if (!proto.isExternal) allProtos.push(proto)
|
|
99
|
-
}
|
|
149
|
+
const allProtos = listInternalPrototypes(idx)
|
|
100
150
|
|
|
101
151
|
for (const proto of allProtos) {
|
|
102
152
|
if (proto.hideFlows && proto.flows.length === 1) {
|
|
@@ -152,6 +202,35 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
152
202
|
.filter(Boolean)
|
|
153
203
|
}, [pickerGroups, filter])
|
|
154
204
|
|
|
205
|
+
const prototypeName = useMemo(() => {
|
|
206
|
+
const currentRoute = normalizeRoutePath(src, basePath) || normalizeRoutePath(rawSrc, basePath)
|
|
207
|
+
if (!currentRoute) return ''
|
|
208
|
+
|
|
209
|
+
let bestMatchName = ''
|
|
210
|
+
let bestMatchLength = -1
|
|
211
|
+
|
|
212
|
+
for (const proto of listInternalPrototypes(prototypeIndex)) {
|
|
213
|
+
const candidateRoutes = [
|
|
214
|
+
`/${proto.dirName}`,
|
|
215
|
+
...(proto.flows || []).map((flow) => flow.route),
|
|
216
|
+
]
|
|
217
|
+
for (const candidate of candidateRoutes) {
|
|
218
|
+
const candidateRoute = normalizeRoutePath(candidate, basePath)
|
|
219
|
+
if (!candidateRoute || candidateRoute === '/') continue
|
|
220
|
+
if (currentRoute === candidateRoute || currentRoute.startsWith(`${candidateRoute}/`)) {
|
|
221
|
+
if (candidateRoute.length > bestMatchLength) {
|
|
222
|
+
bestMatchLength = candidateRoute.length
|
|
223
|
+
bestMatchName = proto.name || ''
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return bestMatchName
|
|
230
|
+
}, [prototypeIndex, src, rawSrc, basePath])
|
|
231
|
+
|
|
232
|
+
const prototypeTitle = prototypeName || label || 'Prototype'
|
|
233
|
+
|
|
155
234
|
const hasPicker = pickerGroups.length > 0
|
|
156
235
|
|
|
157
236
|
useEffect(() => {
|
|
@@ -163,25 +242,129 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
163
242
|
}
|
|
164
243
|
}, [editing, hasPicker])
|
|
165
244
|
|
|
166
|
-
// Exit interactive mode when clicking outside the embed
|
|
167
245
|
useEffect(() => {
|
|
168
|
-
if (!
|
|
246
|
+
if (!showIframe) setIframeLoaded(false)
|
|
247
|
+
}, [showIframe])
|
|
248
|
+
|
|
249
|
+
// Exit interactive mode when clicking outside the embed.
|
|
250
|
+
// Hides iframe immediately for a responsive feel, then captures
|
|
251
|
+
// snapshots in the background with the iframe hidden but still mounted.
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
if (!interactive || expanded) return
|
|
169
254
|
function handlePointerDown(e) {
|
|
170
255
|
if (embedRef.current && !embedRef.current.contains(e.target)) {
|
|
256
|
+
const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
|
|
257
|
+
if (chromeEl) return
|
|
258
|
+
|
|
171
259
|
setInteractive(false)
|
|
260
|
+
if (onUpdate && !isExternal && iframeLoaded && iframeRef.current?.contentWindow) {
|
|
261
|
+
if (iframeRef.current) iframeRef.current.style.visibility = 'hidden'
|
|
262
|
+
const session = ++exitSessionRef.current
|
|
263
|
+
setTimeout(() => {
|
|
264
|
+
if (exitSessionRef.current !== session) return
|
|
265
|
+
requestCapture({ force: true }).then((updates) => {
|
|
266
|
+
if (exitSessionRef.current !== session) return
|
|
267
|
+
const snap = updates?.snapshot
|
|
268
|
+
if (snap) {
|
|
269
|
+
const img = new Image()
|
|
270
|
+
const done = () => {
|
|
271
|
+
if (exitSessionRef.current === session) setShowIframe(false)
|
|
272
|
+
}
|
|
273
|
+
img.onload = done
|
|
274
|
+
img.onerror = done
|
|
275
|
+
img.src = snap
|
|
276
|
+
setTimeout(done, 2000)
|
|
277
|
+
} else {
|
|
278
|
+
setShowIframe(false)
|
|
279
|
+
}
|
|
280
|
+
})
|
|
281
|
+
}, 0)
|
|
282
|
+
} else if (isExternal && showIframe) {
|
|
283
|
+
// External embeds (e.g. Figma) are slow to reload — keep the
|
|
284
|
+
// iframe mounted for 2 min so re-entering is instant.
|
|
285
|
+
const session = ++exitSessionRef.current
|
|
286
|
+
clearTimeout(teardownTimerRef.current)
|
|
287
|
+
teardownTimerRef.current = setTimeout(() => {
|
|
288
|
+
if (exitSessionRef.current !== session) return
|
|
289
|
+
setShowIframe(false)
|
|
290
|
+
}, 2 * 60 * 1000)
|
|
291
|
+
} else {
|
|
292
|
+
setShowIframe(false)
|
|
293
|
+
}
|
|
172
294
|
}
|
|
173
295
|
}
|
|
174
296
|
document.addEventListener('pointerdown', handlePointerDown)
|
|
175
297
|
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
176
|
-
}, [interactive])
|
|
298
|
+
}, [interactive, expanded, onUpdate, isExternal, iframeLoaded, requestCapture])
|
|
177
299
|
|
|
300
|
+
useEffect(() => subscribeCanvasTheme({
|
|
301
|
+
anchorRef: embedRef,
|
|
302
|
+
onTheme: setCanvasTheme,
|
|
303
|
+
}), [])
|
|
304
|
+
|
|
305
|
+
// On canvas theme change, enqueue a background snapshot refresh.
|
|
306
|
+
// Skips the initial render (canvasThemeInitRef tracks first value).
|
|
307
|
+
const canvasThemeInitRef = useRef(true)
|
|
308
|
+
const refreshMetaRef = useRef(null)
|
|
178
309
|
useEffect(() => {
|
|
179
|
-
|
|
180
|
-
|
|
310
|
+
if (canvasThemeInitRef.current) { canvasThemeInitRef.current = false; return }
|
|
311
|
+
if (isExternal || !onUpdate || interactive) return
|
|
312
|
+
const rect = embedRef.current?.getBoundingClientRect()
|
|
313
|
+
enqueueRefresh(widgetId, ({ revealOrder, batchStart }) => {
|
|
314
|
+
return new Promise((resolve) => {
|
|
315
|
+
refreshMetaRef.current = { revealOrder, batchStart, resolve }
|
|
316
|
+
captureOnReadyRef.current = true
|
|
317
|
+
setShowIframe(true)
|
|
318
|
+
// Safety timeout — report failure so retry pass picks it up
|
|
319
|
+
setTimeout(() => { refreshMetaRef.current = null; resolve(false) }, 10000)
|
|
320
|
+
})
|
|
321
|
+
}, rect ? { x: rect.left, y: rect.top } : undefined)
|
|
322
|
+
}, [canvasTheme]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
323
|
+
|
|
324
|
+
// Capture snapshot on first iframe ready (when no existing snapshot)
|
|
325
|
+
useEffect(() => {
|
|
326
|
+
if (!iframeReady || !onUpdate || isExternal) return
|
|
327
|
+
if (!hasSnap) {
|
|
328
|
+
requestCapture()
|
|
181
329
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
330
|
+
}, [iframeReady]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
331
|
+
|
|
332
|
+
// Capture when iframe becomes ready after refresh-thumbnail requested it
|
|
333
|
+
useEffect(() => {
|
|
334
|
+
if (iframeReady && captureOnReadyRef.current) {
|
|
335
|
+
captureOnReadyRef.current = false
|
|
336
|
+
requestCapture().then((updates) => {
|
|
337
|
+
const meta = refreshMetaRef.current
|
|
338
|
+
if (meta) {
|
|
339
|
+
refreshMetaRef.current = null
|
|
340
|
+
const snap = updates?.snapshot
|
|
341
|
+
const reveal = () => {
|
|
342
|
+
if (snap) {
|
|
343
|
+
const img = new Image()
|
|
344
|
+
const done = () => setShowIframe(false)
|
|
345
|
+
img.onload = done
|
|
346
|
+
img.onerror = done
|
|
347
|
+
img.src = snap
|
|
348
|
+
setTimeout(done, 2000)
|
|
349
|
+
} else {
|
|
350
|
+
setShowIframe(false)
|
|
351
|
+
}
|
|
352
|
+
meta.resolve(!!snap)
|
|
353
|
+
}
|
|
354
|
+
// Wait for our reveal slot in the wave
|
|
355
|
+
const elapsed = Date.now() - meta.batchStart
|
|
356
|
+
const targetTime = meta.revealOrder * REVEAL_INTERVAL
|
|
357
|
+
const wait = Math.max(0, targetTime - elapsed)
|
|
358
|
+
setTimeout(reveal, wait)
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
}
|
|
362
|
+
}, [iframeReady, requestCapture])
|
|
363
|
+
|
|
364
|
+
// Cleanup timers on unmount
|
|
365
|
+
useEffect(() => () => {
|
|
366
|
+
clearTimeout(resizeTimerRef.current)
|
|
367
|
+
clearTimeout(teardownTimerRef.current)
|
|
185
368
|
}, [])
|
|
186
369
|
|
|
187
370
|
// Close expanded modal on Escape
|
|
@@ -232,15 +415,20 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
232
415
|
}
|
|
233
416
|
}, [expanded])
|
|
234
417
|
|
|
235
|
-
// Listen for
|
|
418
|
+
// Listen for messages from the embedded prototype iframe
|
|
236
419
|
useEffect(() => {
|
|
237
420
|
function handleMessage(e) {
|
|
238
|
-
if (
|
|
239
|
-
if (e.
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
421
|
+
if (!iframeRef.current?.contentWindow) return
|
|
422
|
+
if (e.source !== iframeRef.current.contentWindow) return
|
|
423
|
+
|
|
424
|
+
// Navigation events
|
|
425
|
+
if (e.data?.type === 'storyboard:embed:navigate') {
|
|
426
|
+
const newSrc = e.data.src
|
|
427
|
+
if (newSrc && newSrc !== src) {
|
|
428
|
+
const originalSrc = readProp(props, 'originalSrc', prototypeEmbedSchema)
|
|
429
|
+
onUpdate?.({ src: newSrc, originalSrc: originalSrc || src })
|
|
430
|
+
}
|
|
431
|
+
return
|
|
244
432
|
}
|
|
245
433
|
}
|
|
246
434
|
window.addEventListener('message', handleMessage)
|
|
@@ -249,7 +437,13 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
249
437
|
|
|
250
438
|
const chromeVars = useMemo(() => getEmbedChromeVars(canvasTheme), [canvasTheme])
|
|
251
439
|
|
|
252
|
-
const enterInteractive = useCallback(() =>
|
|
440
|
+
const enterInteractive = useCallback(() => {
|
|
441
|
+
exitSessionRef.current++
|
|
442
|
+
clearTimeout(teardownTimerRef.current)
|
|
443
|
+
cancelRefresh(widgetId)
|
|
444
|
+
setShowIframe(true)
|
|
445
|
+
setInteractive(true)
|
|
446
|
+
}, [widgetId])
|
|
253
447
|
|
|
254
448
|
// Expose imperative action handlers for WidgetChrome
|
|
255
449
|
useImperativeHandle(ref, () => ({
|
|
@@ -257,18 +451,20 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
257
451
|
if (actionId === 'edit') {
|
|
258
452
|
setEditing(true)
|
|
259
453
|
} else if (actionId === 'expand') {
|
|
454
|
+
setShowIframe(true)
|
|
260
455
|
setExpanded(true)
|
|
261
456
|
} else if (actionId === 'open-external') {
|
|
262
457
|
if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
|
|
263
|
-
} else if (actionId === '
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
458
|
+
} else if (actionId === 'refresh-thumbnail') {
|
|
459
|
+
if (iframeReady && iframeRef.current?.contentWindow) {
|
|
460
|
+
requestCapture()
|
|
461
|
+
} else {
|
|
462
|
+
captureOnReadyRef.current = true
|
|
463
|
+
setShowIframe(true)
|
|
464
|
+
}
|
|
269
465
|
}
|
|
270
466
|
},
|
|
271
|
-
}), [rawSrc,
|
|
467
|
+
}), [rawSrc, iframeReady, requestCapture])
|
|
272
468
|
|
|
273
469
|
function handlePickRoute(route) {
|
|
274
470
|
onUpdate?.({ src: route })
|
|
@@ -297,6 +493,10 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
297
493
|
className={styles.embed}
|
|
298
494
|
style={{ width, height, ...chromeVars }}
|
|
299
495
|
>
|
|
496
|
+
<div className={styles.header}>
|
|
497
|
+
<span className={styles.headerIcon}><CollageFrameIcon size={16} /></span>
|
|
498
|
+
<span className={styles.headerTitle}>{prototypeTitle}</span>
|
|
499
|
+
</div>
|
|
300
500
|
{editing ? (
|
|
301
501
|
<div
|
|
302
502
|
className={styles.pickerPanel}
|
|
@@ -385,25 +585,66 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
385
585
|
className={styles.iframeContainer}
|
|
386
586
|
style={expanded ? { visibility: 'hidden' } : undefined}
|
|
387
587
|
>
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
588
|
+
{/* Snapshot layer — single image */}
|
|
589
|
+
{hasSnap && (
|
|
590
|
+
<img
|
|
591
|
+
src={snapshot}
|
|
592
|
+
className={styles.snapshotImage}
|
|
593
|
+
alt={`${prototypeTitle} snapshot`}
|
|
594
|
+
draggable={false}
|
|
595
|
+
onError={() => setBrokenSnaps(prev => ({ ...prev, [snapshot]: true }))}
|
|
596
|
+
/>
|
|
597
|
+
)}
|
|
598
|
+
|
|
599
|
+
{/* Iframe layer — on top, transparent until loaded */}
|
|
600
|
+
{showIframe && (
|
|
601
|
+
<iframe
|
|
602
|
+
ref={iframeRef}
|
|
603
|
+
src={iframeSrc}
|
|
604
|
+
className={styles.iframe}
|
|
605
|
+
style={{
|
|
606
|
+
width: width / scale,
|
|
607
|
+
height: height / scale,
|
|
608
|
+
transform: `scale(${scale})`,
|
|
609
|
+
transformOrigin: '0 0',
|
|
610
|
+
transition: 'opacity 150ms ease',
|
|
611
|
+
...(iframeLoaded ? {} : { opacity: 0 }),
|
|
612
|
+
}}
|
|
613
|
+
onLoad={() => setIframeLoaded(true)}
|
|
614
|
+
title={`${prototypeTitle} prototype`}
|
|
615
|
+
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
|
|
616
|
+
/>
|
|
617
|
+
)}
|
|
618
|
+
|
|
619
|
+
{/* Placeholder — only when no snapshot and no iframe */}
|
|
620
|
+
{!hasSnap && !showIframe && (
|
|
621
|
+
<div className={styles.placeholder}>
|
|
622
|
+
<CollageFrameIcon size={36} />
|
|
623
|
+
<span className={styles.placeholderLabel}>{`${prototypeTitle} prototype`}</span>
|
|
624
|
+
</div>
|
|
625
|
+
)}
|
|
401
626
|
</div>
|
|
627
|
+
|
|
402
628
|
{!interactive && !expanded && (
|
|
403
629
|
<div
|
|
404
|
-
className={
|
|
405
|
-
|
|
406
|
-
|
|
630
|
+
className={overlayStyles.interactOverlay}
|
|
631
|
+
onClick={(e) => {
|
|
632
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
633
|
+
enterInteractive()
|
|
634
|
+
}}
|
|
635
|
+
role="button"
|
|
636
|
+
tabIndex={0}
|
|
637
|
+
onKeyDown={(e) => {
|
|
638
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
639
|
+
e.preventDefault()
|
|
640
|
+
e.stopPropagation()
|
|
641
|
+
enterInteractive()
|
|
642
|
+
}
|
|
643
|
+
}}
|
|
644
|
+
aria-label={hasSnap ? 'Click to interact with prototype' : 'Click to open prototype'}
|
|
645
|
+
>
|
|
646
|
+
<span className={overlayStyles.interactHint}>{hasSnap ? 'Click to interact' : 'Click to open'}</span>
|
|
647
|
+
</div>
|
|
407
648
|
)}
|
|
408
649
|
</>
|
|
409
650
|
) : (
|
|
@@ -436,6 +677,9 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
436
677
|
function onUp() {
|
|
437
678
|
document.removeEventListener('mousemove', onMove)
|
|
438
679
|
document.removeEventListener('mouseup', onUp)
|
|
680
|
+
// Recapture snapshot after resize (debounced)
|
|
681
|
+
clearTimeout(resizeTimerRef.current)
|
|
682
|
+
resizeTimerRef.current = setTimeout(() => requestCapture(), 1500)
|
|
439
683
|
}
|
|
440
684
|
document.addEventListener('mousemove', onMove)
|
|
441
685
|
document.addEventListener('mouseup', onUp)
|
|
@@ -450,8 +694,13 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
450
694
|
style={expanded ? undefined : { display: 'none' }}
|
|
451
695
|
onClick={() => setExpanded(false)}
|
|
452
696
|
onPointerDown={(e) => e.stopPropagation()}
|
|
453
|
-
onKeyDown={(e) =>
|
|
697
|
+
onKeyDown={(e) => {
|
|
698
|
+
e.stopPropagation()
|
|
699
|
+
if (e.key === 'Escape') setExpanded(false)
|
|
700
|
+
}}
|
|
454
701
|
onWheel={(e) => e.stopPropagation()}
|
|
702
|
+
tabIndex={-1}
|
|
703
|
+
ref={(el) => { if (el && expanded) el.focus() }}
|
|
455
704
|
>
|
|
456
705
|
<div
|
|
457
706
|
ref={modalContainerRef}
|
|
@@ -7,22 +7,92 @@
|
|
|
7
7
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
.header {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: 6px;
|
|
14
|
+
padding: 10px 10px;
|
|
15
|
+
font-size: 12px;
|
|
16
|
+
font-weight: 500;
|
|
17
|
+
color: var(--fgColor-muted, #656d76);
|
|
18
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
19
|
+
border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
|
|
20
|
+
white-space: nowrap;
|
|
21
|
+
overflow: hidden;
|
|
22
|
+
text-overflow: ellipsis;
|
|
23
|
+
user-select: none;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.headerIcon {
|
|
27
|
+
display: inline-flex;
|
|
28
|
+
flex-shrink: 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.headerTitle {
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
text-overflow: ellipsis;
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
.iframeContainer {
|
|
37
|
+
position: relative;
|
|
11
38
|
width: 100%;
|
|
12
|
-
height: 100
|
|
39
|
+
height: calc(100% - 37px);
|
|
13
40
|
overflow: hidden;
|
|
14
41
|
}
|
|
15
42
|
|
|
43
|
+
.placeholder {
|
|
44
|
+
position: absolute;
|
|
45
|
+
inset: 0;
|
|
46
|
+
display: flex;
|
|
47
|
+
flex-direction: column;
|
|
48
|
+
align-items: center;
|
|
49
|
+
justify-content: center;
|
|
50
|
+
gap: 8px;
|
|
51
|
+
color: var(--fgColor-muted, #656d76);
|
|
52
|
+
text-align: center;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.placeholderIcon {
|
|
56
|
+
width: 36px;
|
|
57
|
+
height: 36px;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.placeholderLabel {
|
|
61
|
+
font-size: 13px;
|
|
62
|
+
font-weight: 500;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.spinner {
|
|
66
|
+
width: 24px;
|
|
67
|
+
height: 24px;
|
|
68
|
+
border: 3px solid var(--borderColor-muted, #d0d7de);
|
|
69
|
+
border-top-color: var(--fgColor-accent, #2f81f7);
|
|
70
|
+
border-radius: 50%;
|
|
71
|
+
animation: spin 0.8s linear infinite;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@keyframes spin {
|
|
75
|
+
from { transform: rotate(0deg); }
|
|
76
|
+
to { transform: rotate(360deg); }
|
|
77
|
+
}
|
|
78
|
+
|
|
16
79
|
.iframe {
|
|
17
80
|
border: none;
|
|
18
81
|
display: block;
|
|
82
|
+
position: relative;
|
|
83
|
+
z-index: 1;
|
|
19
84
|
}
|
|
20
85
|
|
|
21
|
-
.
|
|
86
|
+
.snapshotImage {
|
|
22
87
|
position: absolute;
|
|
23
88
|
inset: 0;
|
|
24
|
-
|
|
25
|
-
|
|
89
|
+
width: 100%;
|
|
90
|
+
height: 100%;
|
|
91
|
+
object-fit: cover;
|
|
92
|
+
object-position: top left;
|
|
93
|
+
display: block;
|
|
94
|
+
pointer-events: none;
|
|
95
|
+
transition: opacity 150ms ease;
|
|
26
96
|
}
|
|
27
97
|
|
|
28
98
|
.empty {
|
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
box-shadow: 2px 3px 10px rgba(0, 0, 0, 0.35);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/* Hide own border when parent widget slot is selected (avoid double focus ring) */
|
|
24
|
+
:global([data-widget-selected]) .sticky {
|
|
25
|
+
border-color: transparent;
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
.text {
|
|
24
29
|
padding: 16px 20px;
|
|
25
30
|
margin: 0;
|
|
@@ -13,16 +13,16 @@ describe('stickyNoteSchema', () => {
|
|
|
13
13
|
)
|
|
14
14
|
})
|
|
15
15
|
|
|
16
|
-
it('
|
|
16
|
+
it('includes default values for width/height from config', () => {
|
|
17
17
|
const defaults = getDefaults(stickyNoteSchema)
|
|
18
|
-
expect(defaults).
|
|
19
|
-
expect(defaults).
|
|
18
|
+
expect(defaults).toHaveProperty('width', 270)
|
|
19
|
+
expect(defaults).toHaveProperty('height', 170)
|
|
20
20
|
})
|
|
21
21
|
|
|
22
|
-
it('returns
|
|
22
|
+
it('returns default value when width/height are not saved in props', () => {
|
|
23
23
|
const props = { text: 'hello', color: 'yellow' }
|
|
24
|
-
expect(readProp(props, 'width', stickyNoteSchema)).
|
|
25
|
-
expect(readProp(props, 'height', stickyNoteSchema)).
|
|
24
|
+
expect(readProp(props, 'width', stickyNoteSchema)).toBe(270)
|
|
25
|
+
expect(readProp(props, 'height', stickyNoteSchema)).toBe(170)
|
|
26
26
|
})
|
|
27
27
|
|
|
28
28
|
it('returns saved width/height when present in props', () => {
|
|
@@ -33,11 +33,11 @@ describe('stickyNoteSchema', () => {
|
|
|
33
33
|
})
|
|
34
34
|
|
|
35
35
|
describe('StickyNote', () => {
|
|
36
|
-
it('
|
|
36
|
+
it('applies default dimensions as inline styles when not saved in props', () => {
|
|
37
37
|
const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} />)
|
|
38
38
|
const sticky = container.querySelector('article')
|
|
39
|
-
expect(sticky.style.width).toBe('')
|
|
40
|
-
expect(sticky.style.height).toBe('')
|
|
39
|
+
expect(sticky.style.width).toBe('270px')
|
|
40
|
+
expect(sticky.style.height).toBe('170px')
|
|
41
41
|
})
|
|
42
42
|
|
|
43
43
|
it('applies saved dimensions as inline styles', () => {
|