@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
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
.embed {
|
|
2
|
+
position: relative;
|
|
3
|
+
overflow: hidden;
|
|
4
|
+
background: var(--bgColor-default, #ffffff);
|
|
5
|
+
border: 3px solid var(--borderColor-default, #d0d7de);
|
|
6
|
+
border-radius: 12px;
|
|
7
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
.header {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: 6px;
|
|
14
|
+
padding: 6px 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
|
+
.codepenLogo {
|
|
27
|
+
width: 16px;
|
|
28
|
+
height: 16px;
|
|
29
|
+
flex-shrink: 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.headerTitle {
|
|
33
|
+
overflow: hidden;
|
|
34
|
+
text-overflow: ellipsis;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.iframeContainer {
|
|
38
|
+
width: 100%;
|
|
39
|
+
height: calc(100% - 10px);
|
|
40
|
+
overflow: hidden;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.iframe {
|
|
44
|
+
width: 100%;
|
|
45
|
+
height: 100%;
|
|
46
|
+
border: none;
|
|
47
|
+
display: block;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.resizeHandle {
|
|
51
|
+
position: absolute;
|
|
52
|
+
bottom: 0;
|
|
53
|
+
right: 0;
|
|
54
|
+
width: 16px;
|
|
55
|
+
height: 16px;
|
|
56
|
+
cursor: nwse-resize;
|
|
57
|
+
background: linear-gradient(
|
|
58
|
+
135deg,
|
|
59
|
+
transparent 40%,
|
|
60
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 40%,
|
|
61
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 50%,
|
|
62
|
+
transparent 50%,
|
|
63
|
+
transparent 65%,
|
|
64
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 65%,
|
|
65
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 75%,
|
|
66
|
+
transparent 75%
|
|
67
|
+
);
|
|
68
|
+
opacity: 0;
|
|
69
|
+
transition: opacity 150ms;
|
|
70
|
+
z-index: 2;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.embed:hover ~ .resizeHandle,
|
|
74
|
+
.resizeHandle:hover {
|
|
75
|
+
opacity: 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* Expand modal */
|
|
79
|
+
.expandBackdrop {
|
|
80
|
+
position: fixed;
|
|
81
|
+
inset: 0;
|
|
82
|
+
z-index: 100000;
|
|
83
|
+
background: rgba(0, 0, 0, 0.8);
|
|
84
|
+
display: flex;
|
|
85
|
+
align-items: center;
|
|
86
|
+
justify-content: center;
|
|
87
|
+
animation: expandFadeIn 0.15s ease;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@keyframes expandFadeIn {
|
|
91
|
+
from { opacity: 0; }
|
|
92
|
+
to { opacity: 1; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.expandContainer {
|
|
96
|
+
width: 90vw;
|
|
97
|
+
height: 90vh;
|
|
98
|
+
position: relative;
|
|
99
|
+
border-radius: 12px;
|
|
100
|
+
overflow: hidden;
|
|
101
|
+
background: var(--bgColor-default, #ffffff);
|
|
102
|
+
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
|
|
103
|
+
animation: expandScaleIn 0.2s ease;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@keyframes expandScaleIn {
|
|
107
|
+
from { transform: scale(0.95); opacity: 0; }
|
|
108
|
+
to { transform: scale(1); opacity: 1; }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.expandIframe {
|
|
112
|
+
border: none;
|
|
113
|
+
display: block;
|
|
114
|
+
width: 100%;
|
|
115
|
+
height: 100%;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.expandClose {
|
|
119
|
+
all: unset;
|
|
120
|
+
cursor: pointer;
|
|
121
|
+
position: absolute;
|
|
122
|
+
top: 12px;
|
|
123
|
+
right: 12px;
|
|
124
|
+
width: 32px;
|
|
125
|
+
height: 32px;
|
|
126
|
+
display: flex;
|
|
127
|
+
align-items: center;
|
|
128
|
+
justify-content: center;
|
|
129
|
+
border-radius: 8px;
|
|
130
|
+
background: rgba(0, 0, 0, 0.5);
|
|
131
|
+
color: #ffffff;
|
|
132
|
+
font-size: 16px;
|
|
133
|
+
z-index: 1;
|
|
134
|
+
transition: background 100ms;
|
|
135
|
+
backdrop-filter: blur(4px);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.expandClose:hover {
|
|
139
|
+
background: rgba(0, 0, 0, 0.7);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.emptyState {
|
|
143
|
+
width: 100%;
|
|
144
|
+
height: calc(100% - 10px);
|
|
145
|
+
display: flex;
|
|
146
|
+
flex-direction: column;
|
|
147
|
+
align-items: center;
|
|
148
|
+
justify-content: center;
|
|
149
|
+
gap: 8px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.emptyIcon {
|
|
153
|
+
color: var(--fgColor-muted, #656d76);
|
|
154
|
+
opacity: 0.5;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.emptyLabel {
|
|
158
|
+
color: var(--fgColor-muted, #656d76);
|
|
159
|
+
font-size: 13px;
|
|
160
|
+
font-style: italic;
|
|
161
|
+
}
|
|
@@ -1,19 +1,38 @@
|
|
|
1
|
-
import { useRef, useCallback, useState, useEffect } from 'react'
|
|
1
|
+
import { useRef, useCallback, useState, useEffect, useMemo } from 'react'
|
|
2
2
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
3
3
|
import ResizeHandle from './ResizeHandle.jsx'
|
|
4
|
+
import ComponentErrorBoundary from '../ComponentErrorBoundary.jsx'
|
|
5
|
+
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
4
6
|
import styles from './ComponentWidget.module.css'
|
|
7
|
+
import overlayStyles from './embedOverlay.module.css'
|
|
5
8
|
|
|
6
9
|
/**
|
|
7
10
|
* Renders a live JSX export from a .canvas.jsx companion file.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
11
|
+
*
|
|
12
|
+
* In dev mode (isLocalDev), each component is rendered inside an iframe
|
|
13
|
+
* via the /_storyboard/canvas/isolate middleware. This isolates broken
|
|
14
|
+
* components so they cannot crash the entire canvas page.
|
|
15
|
+
*
|
|
16
|
+
* In production, the component is rendered directly with an ErrorBoundary
|
|
17
|
+
* as a fallback safety net.
|
|
10
18
|
*
|
|
11
19
|
* Double-click the overlay to enter interactive mode (dropdowns, buttons work).
|
|
12
20
|
* Click outside to exit interactive mode.
|
|
13
21
|
*/
|
|
14
|
-
export default function ComponentWidget({
|
|
22
|
+
export default function ComponentWidget({
|
|
23
|
+
component: Component,
|
|
24
|
+
jsxModule,
|
|
25
|
+
exportName,
|
|
26
|
+
canvasTheme,
|
|
27
|
+
isLocalDev,
|
|
28
|
+
width,
|
|
29
|
+
height,
|
|
30
|
+
onUpdate,
|
|
31
|
+
resizable,
|
|
32
|
+
}) {
|
|
15
33
|
const containerRef = useRef(null)
|
|
16
34
|
const [interactive, setInteractive] = useState(false)
|
|
35
|
+
const [showIframe, setShowIframe] = useState(false)
|
|
17
36
|
|
|
18
37
|
const handleResize = useCallback((w, h) => {
|
|
19
38
|
onUpdate?.({ width: w, height: h })
|
|
@@ -27,13 +46,34 @@ export default function ComponentWidget({ component: Component, width, height, o
|
|
|
27
46
|
function handlePointerDown(e) {
|
|
28
47
|
if (containerRef.current && !containerRef.current.contains(e.target)) {
|
|
29
48
|
setInteractive(false)
|
|
49
|
+
setShowIframe(false)
|
|
30
50
|
}
|
|
31
51
|
}
|
|
32
52
|
document.addEventListener('pointerdown', handlePointerDown)
|
|
33
53
|
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
34
54
|
}, [interactive])
|
|
35
55
|
|
|
36
|
-
|
|
56
|
+
// Build iframe src for dev isolation
|
|
57
|
+
const iframeSrc = useMemo(() => {
|
|
58
|
+
if (!isLocalDev || !jsxModule || !exportName) return null
|
|
59
|
+
const basePath = (import.meta.env.BASE_URL || '/').replace(/\/$/, '')
|
|
60
|
+
const params = new URLSearchParams({
|
|
61
|
+
module: jsxModule,
|
|
62
|
+
export: exportName,
|
|
63
|
+
theme: canvasTheme || 'light',
|
|
64
|
+
})
|
|
65
|
+
return `${basePath}/_storyboard/canvas/isolate?${params}`
|
|
66
|
+
}, [isLocalDev, jsxModule, exportName, canvasTheme])
|
|
67
|
+
|
|
68
|
+
const useIframe = isLocalDev && iframeSrc
|
|
69
|
+
|
|
70
|
+
useIframeDevLogs({
|
|
71
|
+
widget: 'ComponentWidget',
|
|
72
|
+
loaded: Boolean(useIframe && showIframe),
|
|
73
|
+
src: iframeSrc,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
if (!useIframe && !Component) return null
|
|
37
77
|
|
|
38
78
|
const sizeStyle = {}
|
|
39
79
|
if (typeof width === 'number') sizeStyle.width = `${width}px`
|
|
@@ -43,13 +83,46 @@ export default function ComponentWidget({ component: Component, width, height, o
|
|
|
43
83
|
<WidgetWrapper>
|
|
44
84
|
<div ref={containerRef} className={styles.container} style={sizeStyle}>
|
|
45
85
|
<div className={styles.content}>
|
|
46
|
-
|
|
86
|
+
{useIframe ? (
|
|
87
|
+
showIframe ? (
|
|
88
|
+
<iframe
|
|
89
|
+
src={iframeSrc}
|
|
90
|
+
className={styles.iframe}
|
|
91
|
+
title={exportName || 'Component widget'}
|
|
92
|
+
sandbox="allow-same-origin allow-scripts"
|
|
93
|
+
/>
|
|
94
|
+
) : (
|
|
95
|
+
<div className={styles.placeholder} />
|
|
96
|
+
)
|
|
97
|
+
) : Component ? (
|
|
98
|
+
<ComponentErrorBoundary name={exportName}>
|
|
99
|
+
<Component />
|
|
100
|
+
</ComponentErrorBoundary>
|
|
101
|
+
) : null}
|
|
47
102
|
</div>
|
|
48
103
|
{!interactive && (
|
|
49
104
|
<div
|
|
50
|
-
className={
|
|
51
|
-
|
|
52
|
-
|
|
105
|
+
className={overlayStyles.interactOverlay}
|
|
106
|
+
onClick={(e) => {
|
|
107
|
+
// Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
|
|
108
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
109
|
+
if (useIframe) setShowIframe(true)
|
|
110
|
+
enterInteractive()
|
|
111
|
+
}}
|
|
112
|
+
role="button"
|
|
113
|
+
tabIndex={0}
|
|
114
|
+
onKeyDown={(e) => {
|
|
115
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
116
|
+
e.preventDefault()
|
|
117
|
+
e.stopPropagation()
|
|
118
|
+
if (useIframe) setShowIframe(true)
|
|
119
|
+
enterInteractive()
|
|
120
|
+
}
|
|
121
|
+
}}
|
|
122
|
+
aria-label="Click to interact with component"
|
|
123
|
+
>
|
|
124
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
125
|
+
</div>
|
|
53
126
|
)}
|
|
54
127
|
{resizable && (
|
|
55
128
|
<ResizeHandle
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
.container {
|
|
2
2
|
position: relative;
|
|
3
|
-
overflow:
|
|
3
|
+
overflow: hidden;
|
|
4
4
|
min-width: 100px;
|
|
5
5
|
min-height: 60px;
|
|
6
|
+
background: var(--bgColor-default, #ffffff);
|
|
7
|
+
width: 100%;
|
|
8
|
+
height: 100%;
|
|
6
9
|
}
|
|
7
10
|
|
|
8
11
|
.content {
|
|
@@ -10,9 +13,14 @@
|
|
|
10
13
|
height: 100%;
|
|
11
14
|
}
|
|
12
15
|
|
|
13
|
-
.
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
.iframe {
|
|
17
|
+
display: block;
|
|
18
|
+
width: 100%;
|
|
19
|
+
height: 100%;
|
|
20
|
+
border: none;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
.placeholder {
|
|
24
|
+
width: 100%;
|
|
25
|
+
height: 100%;
|
|
18
26
|
}
|
|
@@ -4,10 +4,36 @@ import WidgetWrapper from './WidgetWrapper.jsx'
|
|
|
4
4
|
import { readProp } from './widgetProps.js'
|
|
5
5
|
import { schemas } from './widgetConfig.js'
|
|
6
6
|
import { toFigmaEmbedUrl, getFigmaTitle, getFigmaType, isFigmaUrl } from './figmaUrl.js'
|
|
7
|
+
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
7
8
|
import styles from './FigmaEmbed.module.css'
|
|
9
|
+
import overlayStyles from './embedOverlay.module.css'
|
|
8
10
|
|
|
9
11
|
const figmaEmbedSchema = schemas['figma-embed']
|
|
10
12
|
|
|
13
|
+
/** Feather-icons figma icon (monochrome, stroke-based) */
|
|
14
|
+
function FigmaIcon({ size = 32, className }) {
|
|
15
|
+
return (
|
|
16
|
+
<svg
|
|
17
|
+
className={className}
|
|
18
|
+
width={size}
|
|
19
|
+
height={size}
|
|
20
|
+
viewBox="0 0 24 24"
|
|
21
|
+
fill="none"
|
|
22
|
+
stroke="currentColor"
|
|
23
|
+
strokeWidth="2"
|
|
24
|
+
strokeLinecap="round"
|
|
25
|
+
strokeLinejoin="round"
|
|
26
|
+
aria-hidden="true"
|
|
27
|
+
>
|
|
28
|
+
<path d="M5 5.5A3.5 3.5 0 0 1 8.5 2H12v7H8.5A3.5 3.5 0 0 1 5 5.5z" />
|
|
29
|
+
<path d="M12 2h3.5a3.5 3.5 0 1 1 0 7H12V2z" />
|
|
30
|
+
<path d="M12 12.5a3.5 3.5 0 1 1 7 0 3.5 3.5 0 1 1-7 0z" />
|
|
31
|
+
<path d="M5 19.5A3.5 3.5 0 0 1 8.5 16H12v3.5a3.5 3.5 0 1 1-7 0z" />
|
|
32
|
+
<path d="M5 12.5A3.5 3.5 0 0 1 8.5 9H12v7H8.5A3.5 3.5 0 0 1 5 12.5z" />
|
|
33
|
+
</svg>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
11
37
|
/** Inline Figma logo SVG */
|
|
12
38
|
function FigmaLogo() {
|
|
13
39
|
return (
|
|
@@ -29,11 +55,15 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
|
|
|
29
55
|
const height = readProp(props, 'height', figmaEmbedSchema)
|
|
30
56
|
|
|
31
57
|
const [interactive, setInteractive] = useState(false)
|
|
58
|
+
const [showIframe, setShowIframe] = useState(true)
|
|
32
59
|
const [expanded, setExpanded] = useState(false)
|
|
33
60
|
|
|
34
61
|
const iframeRef = useRef(null)
|
|
62
|
+
const embedRef = useRef(null)
|
|
35
63
|
const inlineContainerRef = useRef(null)
|
|
36
64
|
const modalContainerRef = useRef(null)
|
|
65
|
+
const teardownTimerRef = useRef(null)
|
|
66
|
+
const exitSessionRef = useRef(0)
|
|
37
67
|
|
|
38
68
|
// Validate URL at render time — only embed known Figma URLs
|
|
39
69
|
const isValid = useMemo(() => isFigmaUrl(url), [url])
|
|
@@ -42,7 +72,38 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
|
|
|
42
72
|
const figmaType = useMemo(() => getFigmaType(url), [url])
|
|
43
73
|
const typeLabel = figmaType ? TYPE_LABELS[figmaType] : 'Figma'
|
|
44
74
|
|
|
45
|
-
|
|
75
|
+
useIframeDevLogs({
|
|
76
|
+
widget: 'FigmaEmbed',
|
|
77
|
+
loaded: showIframe && Boolean(embedUrl),
|
|
78
|
+
src: embedUrl,
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
const enterInteractive = useCallback(() => {
|
|
82
|
+
exitSessionRef.current++
|
|
83
|
+
clearTimeout(teardownTimerRef.current)
|
|
84
|
+
setShowIframe(true)
|
|
85
|
+
setInteractive(true)
|
|
86
|
+
}, [])
|
|
87
|
+
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (!interactive || expanded) return
|
|
90
|
+
function handlePointerDown(e) {
|
|
91
|
+
if (embedRef.current && !embedRef.current.contains(e.target)) {
|
|
92
|
+
setInteractive(false)
|
|
93
|
+
// Keep iframe alive for 5 min — Figma is slow to reload
|
|
94
|
+
const session = ++exitSessionRef.current
|
|
95
|
+
clearTimeout(teardownTimerRef.current)
|
|
96
|
+
teardownTimerRef.current = setTimeout(() => {
|
|
97
|
+
if (exitSessionRef.current !== session) return
|
|
98
|
+
setShowIframe(false)
|
|
99
|
+
}, 5 * 60 * 1000)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
103
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
104
|
+
}, [interactive, expanded])
|
|
105
|
+
|
|
106
|
+
useEffect(() => () => clearTimeout(teardownTimerRef.current), [])
|
|
46
107
|
|
|
47
108
|
// Close expanded modal on Escape
|
|
48
109
|
useEffect(() => {
|
|
@@ -96,6 +157,7 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
|
|
|
96
157
|
if (actionId === 'open-external') {
|
|
97
158
|
if (url) window.open(url, '_blank', 'noopener')
|
|
98
159
|
} else if (actionId === 'expand') {
|
|
160
|
+
setShowIframe(true)
|
|
99
161
|
setExpanded(true)
|
|
100
162
|
}
|
|
101
163
|
},
|
|
@@ -104,39 +166,58 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
|
|
|
104
166
|
return (
|
|
105
167
|
<>
|
|
106
168
|
<WidgetWrapper>
|
|
107
|
-
<div className={styles.embed} style={{ width, height }}>
|
|
169
|
+
<div ref={embedRef} className={styles.embed} style={{ width, height }}>
|
|
108
170
|
<div className={styles.header}>
|
|
109
171
|
<FigmaLogo />
|
|
110
172
|
<span className={styles.headerTitle}>{title}</span>
|
|
111
173
|
</div>
|
|
112
174
|
{embedUrl ? (
|
|
113
175
|
<>
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
176
|
+
{showIframe ? (
|
|
177
|
+
<div
|
|
178
|
+
ref={inlineContainerRef}
|
|
179
|
+
className={styles.iframeContainer}
|
|
180
|
+
style={expanded ? { visibility: 'hidden' } : undefined}
|
|
181
|
+
>
|
|
182
|
+
<iframe
|
|
183
|
+
ref={iframeRef}
|
|
184
|
+
src={embedUrl}
|
|
185
|
+
className={styles.iframe}
|
|
186
|
+
title={`Figma ${typeLabel}: ${title}`}
|
|
187
|
+
allowFullScreen
|
|
188
|
+
/>
|
|
189
|
+
</div>
|
|
190
|
+
) : (
|
|
191
|
+
<div className={styles.iframeContainer} />
|
|
192
|
+
)}
|
|
127
193
|
{!interactive && !expanded && (
|
|
128
194
|
<div
|
|
129
|
-
className={
|
|
130
|
-
|
|
131
|
-
|
|
195
|
+
className={overlayStyles.interactOverlay}
|
|
196
|
+
onClick={(e) => {
|
|
197
|
+
// Don't enter interactive mode for modifier clicks (shift/meta/ctrl for multi-select)
|
|
198
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
199
|
+
enterInteractive()
|
|
200
|
+
}}
|
|
201
|
+
role="button"
|
|
202
|
+
tabIndex={0}
|
|
203
|
+
onKeyDown={(e) => {
|
|
204
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
205
|
+
e.preventDefault()
|
|
206
|
+
e.stopPropagation()
|
|
207
|
+
enterInteractive()
|
|
208
|
+
}
|
|
209
|
+
}}
|
|
210
|
+
aria-label="Click to interact with Figma embed"
|
|
211
|
+
>
|
|
212
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
213
|
+
</div>
|
|
132
214
|
)}
|
|
133
215
|
</>
|
|
134
216
|
) : (
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
</div>
|
|
217
|
+
<div className={styles.emptyState}>
|
|
218
|
+
<FigmaIcon size={32} className={styles.emptyIcon} />
|
|
219
|
+
<span className={styles.emptyLabel}>No Figma URL</span>
|
|
220
|
+
</div>
|
|
140
221
|
)}
|
|
141
222
|
</div>
|
|
142
223
|
{resizable && (
|
|
@@ -171,8 +252,13 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, re
|
|
|
171
252
|
style={expanded && embedUrl ? undefined : { display: 'none' }}
|
|
172
253
|
onClick={() => setExpanded(false)}
|
|
173
254
|
onPointerDown={(e) => e.stopPropagation()}
|
|
174
|
-
onKeyDown={(e) =>
|
|
255
|
+
onKeyDown={(e) => {
|
|
256
|
+
e.stopPropagation()
|
|
257
|
+
if (e.key === 'Escape') setExpanded(false)
|
|
258
|
+
}}
|
|
175
259
|
onWheel={(e) => e.stopPropagation()}
|
|
260
|
+
tabIndex={-1}
|
|
261
|
+
ref={(el) => { if (el && expanded) el.focus() }}
|
|
176
262
|
>
|
|
177
263
|
<div
|
|
178
264
|
ref={modalContainerRef}
|
|
@@ -47,13 +47,6 @@
|
|
|
47
47
|
display: block;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
.dragOverlay {
|
|
51
|
-
position: absolute;
|
|
52
|
-
inset: 0;
|
|
53
|
-
z-index: 1;
|
|
54
|
-
cursor: grab;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
50
|
.resizeHandle {
|
|
58
51
|
position: absolute;
|
|
59
52
|
bottom: 0;
|
|
@@ -145,3 +138,24 @@
|
|
|
145
138
|
.expandClose:hover {
|
|
146
139
|
background: rgba(0, 0, 0, 0.7);
|
|
147
140
|
}
|
|
141
|
+
|
|
142
|
+
.emptyState {
|
|
143
|
+
width: 100%;
|
|
144
|
+
height: calc(100% - 10px);
|
|
145
|
+
display: flex;
|
|
146
|
+
flex-direction: column;
|
|
147
|
+
align-items: center;
|
|
148
|
+
justify-content: center;
|
|
149
|
+
gap: 8px;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.emptyIcon {
|
|
153
|
+
color: var(--fgColor-muted, #656d76);
|
|
154
|
+
opacity: 0.5;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
.emptyLabel {
|
|
158
|
+
color: var(--fgColor-muted, #656d76);
|
|
159
|
+
font-size: 13px;
|
|
160
|
+
font-style: italic;
|
|
161
|
+
}
|