@dfosco/storyboard-react 3.11.1-beta.0 → 3.11.1
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/Viewfinder.jsx +5 -3
- package/src/__mocks__/virtual-storyboard-data-index.js +1 -0
- package/src/canvas/CanvasControls.jsx +2 -59
- package/src/canvas/CanvasControls.module.css +0 -29
- package/src/canvas/CanvasPage.bridge.test.jsx +68 -42
- package/src/canvas/CanvasPage.jsx +791 -68
- package/src/canvas/CanvasPage.module.css +47 -2
- package/src/canvas/CanvasPage.multiselect.test.jsx +345 -0
- package/src/canvas/canvasApi.js +8 -0
- package/src/canvas/computeCanvasBounds.test.js +121 -0
- package/src/canvas/useCanvas.js +2 -1
- package/src/canvas/useUndoRedo.js +86 -0
- package/src/canvas/useUndoRedo.test.js +231 -0
- package/src/canvas/widgets/ComponentWidget.jsx +9 -7
- package/src/canvas/widgets/FigmaEmbed.jsx +195 -0
- package/src/canvas/widgets/FigmaEmbed.module.css +147 -0
- package/src/canvas/widgets/ImageWidget.jsx +115 -0
- package/src/canvas/widgets/ImageWidget.module.css +39 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +25 -8
- package/src/canvas/widgets/MarkdownBlock.test.jsx +53 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +132 -26
- package/src/canvas/widgets/PrototypeEmbed.module.css +66 -2
- package/src/canvas/widgets/StickyNote.jsx +21 -16
- package/src/canvas/widgets/StickyNote.test.jsx +24 -4
- package/src/canvas/widgets/WidgetChrome.jsx +276 -50
- package/src/canvas/widgets/WidgetChrome.module.css +91 -10
- package/src/canvas/widgets/figmaUrl.js +118 -0
- package/src/canvas/widgets/figmaUrl.test.js +139 -0
- package/src/canvas/widgets/index.js +4 -0
- package/src/canvas/widgets/widgetConfig.js +74 -6
- package/src/canvas/widgets/widgetConfig.test.js +46 -0
- package/src/canvas/widgets/widgetProps.js +2 -0
- package/src/context.jsx +34 -4
- package/src/context.test.jsx +13 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { renderHook, act } from '@testing-library/react'
|
|
2
|
+
import useUndoRedo from './useUndoRedo.js'
|
|
3
|
+
|
|
4
|
+
describe('useUndoRedo', () => {
|
|
5
|
+
it('starts with canUndo and canRedo as false', () => {
|
|
6
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
7
|
+
expect(result.current.canUndo).toBe(false)
|
|
8
|
+
expect(result.current.canRedo).toBe(false)
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('can undo after a snapshot', () => {
|
|
12
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
13
|
+
const widgets = [{ id: '1', type: 'sticky-note', props: { text: 'a' }, position: { x: 0, y: 0 } }]
|
|
14
|
+
|
|
15
|
+
act(() => result.current.snapshot(widgets, 'add'))
|
|
16
|
+
|
|
17
|
+
expect(result.current.canUndo).toBe(true)
|
|
18
|
+
expect(result.current.canRedo).toBe(false)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('undo returns the previous state and enables redo', () => {
|
|
22
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
23
|
+
const before = [{ id: '1', props: { text: 'a' } }]
|
|
24
|
+
const after = [{ id: '1', props: { text: 'b' } }]
|
|
25
|
+
|
|
26
|
+
act(() => result.current.snapshot(before, 'edit', '1'))
|
|
27
|
+
|
|
28
|
+
let restored
|
|
29
|
+
act(() => { restored = result.current.undo(after) })
|
|
30
|
+
|
|
31
|
+
expect(restored).toEqual(before)
|
|
32
|
+
expect(result.current.canUndo).toBe(false)
|
|
33
|
+
expect(result.current.canRedo).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('redo returns the next state', () => {
|
|
37
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
38
|
+
const before = [{ id: '1', props: { text: 'a' } }]
|
|
39
|
+
const after = [{ id: '1', props: { text: 'b' } }]
|
|
40
|
+
|
|
41
|
+
act(() => result.current.snapshot(before, 'edit', '1'))
|
|
42
|
+
act(() => { result.current.undo(after) })
|
|
43
|
+
|
|
44
|
+
let redone
|
|
45
|
+
act(() => { redone = result.current.redo(before) })
|
|
46
|
+
|
|
47
|
+
expect(redone).toEqual(after)
|
|
48
|
+
expect(result.current.canUndo).toBe(true)
|
|
49
|
+
expect(result.current.canRedo).toBe(false)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('new mutation after undo clears the redo chain', () => {
|
|
53
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
54
|
+
const s0 = [{ id: '1' }]
|
|
55
|
+
const s1 = [{ id: '1' }, { id: '2' }]
|
|
56
|
+
const s2 = [{ id: '1' }, { id: '3' }] // eslint-disable-line no-unused-vars
|
|
57
|
+
|
|
58
|
+
act(() => result.current.snapshot(s0, 'add'))
|
|
59
|
+
act(() => result.current.undo(s1))
|
|
60
|
+
expect(result.current.canRedo).toBe(true)
|
|
61
|
+
|
|
62
|
+
// New mutation breaks redo
|
|
63
|
+
act(() => result.current.snapshot(s0, 'add'))
|
|
64
|
+
expect(result.current.canRedo).toBe(false)
|
|
65
|
+
expect(result.current.canUndo).toBe(true)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('supports multi-step undo-redo-undo chains', () => {
|
|
69
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
70
|
+
const s0 = [{ id: '1' }]
|
|
71
|
+
const s1 = [{ id: '1' }, { id: '2' }]
|
|
72
|
+
const s2 = [{ id: '1' }, { id: '2' }, { id: '3' }]
|
|
73
|
+
const s3 = [{ id: '1' }, { id: '2' }, { id: '3' }, { id: '4' }]
|
|
74
|
+
|
|
75
|
+
// Build history: s0 → s1 → s2 → s3
|
|
76
|
+
act(() => result.current.snapshot(s0, 'add'))
|
|
77
|
+
act(() => result.current.snapshot(s1, 'add'))
|
|
78
|
+
act(() => result.current.snapshot(s2, 'add'))
|
|
79
|
+
|
|
80
|
+
// present = s3, past = [s0, s1, s2]
|
|
81
|
+
// Undo to s2
|
|
82
|
+
let r
|
|
83
|
+
act(() => { r = result.current.undo(s3) })
|
|
84
|
+
expect(r).toEqual(s2)
|
|
85
|
+
|
|
86
|
+
// Undo to s1
|
|
87
|
+
act(() => { r = result.current.undo(s2) })
|
|
88
|
+
expect(r).toEqual(s1)
|
|
89
|
+
|
|
90
|
+
// Redo to s2
|
|
91
|
+
act(() => { r = result.current.redo(s1) })
|
|
92
|
+
expect(r).toEqual(s2)
|
|
93
|
+
|
|
94
|
+
// Redo to s3
|
|
95
|
+
act(() => { r = result.current.redo(s2) })
|
|
96
|
+
expect(r).toEqual(s3)
|
|
97
|
+
|
|
98
|
+
// Undo to s2 again
|
|
99
|
+
act(() => { r = result.current.undo(s3) })
|
|
100
|
+
expect(r).toEqual(s2)
|
|
101
|
+
|
|
102
|
+
// Undo to s1
|
|
103
|
+
act(() => { r = result.current.undo(s2) })
|
|
104
|
+
expect(r).toEqual(s1)
|
|
105
|
+
|
|
106
|
+
// Undo to s0
|
|
107
|
+
act(() => { r = result.current.undo(s1) })
|
|
108
|
+
expect(r).toEqual(s0)
|
|
109
|
+
|
|
110
|
+
// Can't undo further
|
|
111
|
+
expect(result.current.canUndo).toBe(false)
|
|
112
|
+
act(() => { r = result.current.undo(s0) })
|
|
113
|
+
expect(r).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('coalesces edits to the same widget within timeout', () => {
|
|
117
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
118
|
+
const s0 = [{ id: '1', props: { text: '' } }]
|
|
119
|
+
|
|
120
|
+
// First edit — creates snapshot
|
|
121
|
+
act(() => result.current.snapshot(s0, 'edit', '1'))
|
|
122
|
+
expect(result.current.canUndo).toBe(true)
|
|
123
|
+
|
|
124
|
+
// Second edit to same widget within 2s — coalesced, no new snapshot
|
|
125
|
+
act(() => result.current.snapshot(s0, 'edit', '1'))
|
|
126
|
+
// Still only one entry in past
|
|
127
|
+
let r
|
|
128
|
+
act(() => { r = result.current.undo([{ id: '1', props: { text: 'abc' } }]) })
|
|
129
|
+
expect(r).toEqual(s0)
|
|
130
|
+
// No more undo after that
|
|
131
|
+
expect(result.current.canUndo).toBe(false)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('does NOT coalesce edits to different widgets', () => {
|
|
135
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
136
|
+
const s0 = [{ id: '1' }, { id: '2' }]
|
|
137
|
+
const s1 = [{ id: '1', props: { text: 'a' } }, { id: '2' }]
|
|
138
|
+
|
|
139
|
+
act(() => result.current.snapshot(s0, 'edit', '1'))
|
|
140
|
+
act(() => result.current.snapshot(s1, 'edit', '2'))
|
|
141
|
+
|
|
142
|
+
// Two entries in past
|
|
143
|
+
expect(result.current.canUndo).toBe(true)
|
|
144
|
+
act(() => { result.current.undo([]) })
|
|
145
|
+
expect(result.current.canUndo).toBe(true)
|
|
146
|
+
act(() => { result.current.undo([]) })
|
|
147
|
+
expect(result.current.canUndo).toBe(false)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('does NOT coalesce edit after a different action type', () => {
|
|
151
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
152
|
+
const s0 = [{ id: '1' }]
|
|
153
|
+
const s1 = [{ id: '1' }, { id: '2' }]
|
|
154
|
+
|
|
155
|
+
act(() => result.current.snapshot(s0, 'edit', '1'))
|
|
156
|
+
act(() => result.current.snapshot(s1, 'add'))
|
|
157
|
+
act(() => result.current.snapshot(s1, 'edit', '1'))
|
|
158
|
+
|
|
159
|
+
// Three entries in past (edit + add + edit — not coalesced because add broke the sequence)
|
|
160
|
+
act(() => { result.current.undo([]) })
|
|
161
|
+
act(() => { result.current.undo([]) })
|
|
162
|
+
act(() => { result.current.undo([]) })
|
|
163
|
+
expect(result.current.canUndo).toBe(false)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('reset clears all history', () => {
|
|
167
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
168
|
+
act(() => result.current.snapshot([{ id: '1' }], 'add'))
|
|
169
|
+
act(() => result.current.snapshot([{ id: '1' }, { id: '2' }], 'add'))
|
|
170
|
+
expect(result.current.canUndo).toBe(true)
|
|
171
|
+
|
|
172
|
+
act(() => result.current.reset())
|
|
173
|
+
expect(result.current.canUndo).toBe(false)
|
|
174
|
+
expect(result.current.canRedo).toBe(false)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('undo returns null when history is empty', () => {
|
|
178
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
179
|
+
let r
|
|
180
|
+
act(() => { r = result.current.undo([]) })
|
|
181
|
+
expect(r).toBeNull()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('redo returns null when future is empty', () => {
|
|
185
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
186
|
+
let r
|
|
187
|
+
act(() => { r = result.current.redo([]) })
|
|
188
|
+
expect(r).toBeNull()
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('snapshots are deep clones (mutations do not leak)', () => {
|
|
192
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
193
|
+
const widgets = [{ id: '1', props: { text: 'original' } }]
|
|
194
|
+
|
|
195
|
+
act(() => result.current.snapshot(widgets, 'add'))
|
|
196
|
+
|
|
197
|
+
// Mutate the original array
|
|
198
|
+
widgets[0].props.text = 'mutated'
|
|
199
|
+
|
|
200
|
+
let restored
|
|
201
|
+
act(() => { restored = result.current.undo(widgets) })
|
|
202
|
+
expect(restored[0].props.text).toBe('original')
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('caps history at 100 entries', () => {
|
|
206
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
207
|
+
|
|
208
|
+
for (let i = 0; i < 110; i++) {
|
|
209
|
+
act(() => result.current.snapshot([{ id: String(i) }], 'add'))
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Should be capped at 100 — undo 100 times, then can't undo further
|
|
213
|
+
let count = 0
|
|
214
|
+
let r = true
|
|
215
|
+
while (r !== null) {
|
|
216
|
+
act(() => { r = result.current.undo([]) })
|
|
217
|
+
if (r !== null) count++
|
|
218
|
+
}
|
|
219
|
+
expect(count).toBe(100)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('snapshots null widgets as empty array (first widget on new canvas)', () => {
|
|
223
|
+
const { result } = renderHook(() => useUndoRedo())
|
|
224
|
+
act(() => result.current.snapshot(null, 'add'))
|
|
225
|
+
expect(result.current.canUndo).toBe(true)
|
|
226
|
+
|
|
227
|
+
let restored
|
|
228
|
+
act(() => { restored = result.current.undo([{ id: '1' }]) })
|
|
229
|
+
expect(restored).toEqual([])
|
|
230
|
+
})
|
|
231
|
+
})
|
|
@@ -11,7 +11,7 @@ import styles from './ComponentWidget.module.css'
|
|
|
11
11
|
* Double-click the overlay to enter interactive mode (dropdowns, buttons work).
|
|
12
12
|
* Click outside to exit interactive mode.
|
|
13
13
|
*/
|
|
14
|
-
export default function ComponentWidget({ component: Component, width, height, onUpdate }) {
|
|
14
|
+
export default function ComponentWidget({ component: Component, width, height, onUpdate, resizable }) {
|
|
15
15
|
const containerRef = useRef(null)
|
|
16
16
|
const [interactive, setInteractive] = useState(false)
|
|
17
17
|
|
|
@@ -51,12 +51,14 @@ export default function ComponentWidget({ component: Component, width, height, o
|
|
|
51
51
|
onDoubleClick={enterInteractive}
|
|
52
52
|
/>
|
|
53
53
|
)}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
54
|
+
{resizable && (
|
|
55
|
+
<ResizeHandle
|
|
56
|
+
targetRef={containerRef}
|
|
57
|
+
minWidth={100}
|
|
58
|
+
minHeight={60}
|
|
59
|
+
onResize={handleResize}
|
|
60
|
+
/>
|
|
61
|
+
)}
|
|
60
62
|
</div>
|
|
61
63
|
</WidgetWrapper>
|
|
62
64
|
)
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { forwardRef, useImperativeHandle, useMemo, useCallback, useState, useEffect, useRef } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
3
|
+
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
4
|
+
import { readProp } from './widgetProps.js'
|
|
5
|
+
import { schemas } from './widgetConfig.js'
|
|
6
|
+
import { toFigmaEmbedUrl, getFigmaTitle, getFigmaType, isFigmaUrl } from './figmaUrl.js'
|
|
7
|
+
import styles from './FigmaEmbed.module.css'
|
|
8
|
+
|
|
9
|
+
const figmaEmbedSchema = schemas['figma-embed']
|
|
10
|
+
|
|
11
|
+
/** Inline Figma logo SVG */
|
|
12
|
+
function FigmaLogo() {
|
|
13
|
+
return (
|
|
14
|
+
<svg className={styles.figmaLogo} viewBox="0 0 38 57" fill="none" aria-hidden="true">
|
|
15
|
+
<path d="M19 28.5a9.5 9.5 0 1 1 19 0 9.5 9.5 0 0 1-19 0z" fill="#1ABCFE" />
|
|
16
|
+
<path d="M0 47.5A9.5 9.5 0 0 1 9.5 38H19v9.5a9.5 9.5 0 1 1-19 0z" fill="#0ACF83" />
|
|
17
|
+
<path d="M19 0v19h9.5a9.5 9.5 0 1 0 0-19H19z" fill="#FF7262" />
|
|
18
|
+
<path d="M0 9.5A9.5 9.5 0 0 0 9.5 19H19V0H9.5A9.5 9.5 0 0 0 0 9.5z" fill="#F24E1E" />
|
|
19
|
+
<path d="M0 28.5A9.5 9.5 0 0 0 9.5 38H19V19H9.5A9.5 9.5 0 0 0 0 28.5z" fill="#A259FF" />
|
|
20
|
+
</svg>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const TYPE_LABELS = { board: 'Board', design: 'Design', proto: 'Prototype' }
|
|
25
|
+
|
|
26
|
+
export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, ref) {
|
|
27
|
+
const url = readProp(props, 'url', figmaEmbedSchema)
|
|
28
|
+
const width = readProp(props, 'width', figmaEmbedSchema)
|
|
29
|
+
const height = readProp(props, 'height', figmaEmbedSchema)
|
|
30
|
+
|
|
31
|
+
const [interactive, setInteractive] = useState(false)
|
|
32
|
+
const [expanded, setExpanded] = useState(false)
|
|
33
|
+
|
|
34
|
+
const iframeRef = useRef(null)
|
|
35
|
+
const inlineContainerRef = useRef(null)
|
|
36
|
+
const modalContainerRef = useRef(null)
|
|
37
|
+
|
|
38
|
+
// Validate URL at render time — only embed known Figma URLs
|
|
39
|
+
const isValid = useMemo(() => isFigmaUrl(url), [url])
|
|
40
|
+
const embedUrl = useMemo(() => (isValid ? toFigmaEmbedUrl(url) : ''), [url, isValid])
|
|
41
|
+
const title = useMemo(() => (url ? getFigmaTitle(url) : 'Figma'), [url])
|
|
42
|
+
const figmaType = useMemo(() => getFigmaType(url), [url])
|
|
43
|
+
const typeLabel = figmaType ? TYPE_LABELS[figmaType] : 'Figma'
|
|
44
|
+
|
|
45
|
+
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
46
|
+
|
|
47
|
+
// Close expanded modal on Escape
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (!expanded) return
|
|
50
|
+
function handleKeyDown(e) {
|
|
51
|
+
if (e.key === 'Escape') {
|
|
52
|
+
e.stopPropagation()
|
|
53
|
+
setExpanded(false)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
document.addEventListener('keydown', handleKeyDown, true)
|
|
57
|
+
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
|
58
|
+
}, [expanded])
|
|
59
|
+
|
|
60
|
+
// Reparent iframe DOM node between inline container and modal.
|
|
61
|
+
// Uses moveBefore() (Chrome 133+) which preserves the iframe's
|
|
62
|
+
// browsing context — no reload. Falls back to appendChild.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const iframe = iframeRef.current
|
|
65
|
+
if (!iframe) return
|
|
66
|
+
|
|
67
|
+
if (expanded && modalContainerRef.current) {
|
|
68
|
+
iframe._savedClassName = iframe.className
|
|
69
|
+
iframe._savedStyle = iframe.getAttribute('style') || ''
|
|
70
|
+
iframe.className = styles.expandIframe
|
|
71
|
+
iframe.removeAttribute('style')
|
|
72
|
+
const target = modalContainerRef.current
|
|
73
|
+
if (target.moveBefore) {
|
|
74
|
+
target.moveBefore(iframe, target.firstChild)
|
|
75
|
+
} else {
|
|
76
|
+
target.prepend(iframe)
|
|
77
|
+
}
|
|
78
|
+
} else if (!expanded && inlineContainerRef.current) {
|
|
79
|
+
if (iframe._savedClassName !== undefined) {
|
|
80
|
+
iframe.className = iframe._savedClassName
|
|
81
|
+
iframe.setAttribute('style', iframe._savedStyle)
|
|
82
|
+
delete iframe._savedClassName
|
|
83
|
+
delete iframe._savedStyle
|
|
84
|
+
}
|
|
85
|
+
const target = inlineContainerRef.current
|
|
86
|
+
if (target.moveBefore) {
|
|
87
|
+
target.moveBefore(iframe, null)
|
|
88
|
+
} else {
|
|
89
|
+
target.appendChild(iframe)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}, [expanded])
|
|
93
|
+
|
|
94
|
+
useImperativeHandle(ref, () => ({
|
|
95
|
+
handleAction(actionId) {
|
|
96
|
+
if (actionId === 'open-external') {
|
|
97
|
+
if (url) window.open(url, '_blank', 'noopener')
|
|
98
|
+
} else if (actionId === 'expand') {
|
|
99
|
+
setExpanded(true)
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
}), [url])
|
|
103
|
+
|
|
104
|
+
return (
|
|
105
|
+
<>
|
|
106
|
+
<WidgetWrapper>
|
|
107
|
+
<div className={styles.embed} style={{ width, height }}>
|
|
108
|
+
<div className={styles.header}>
|
|
109
|
+
<FigmaLogo />
|
|
110
|
+
<span className={styles.headerTitle}>{title}</span>
|
|
111
|
+
</div>
|
|
112
|
+
{embedUrl ? (
|
|
113
|
+
<>
|
|
114
|
+
<div
|
|
115
|
+
ref={inlineContainerRef}
|
|
116
|
+
className={styles.iframeContainer}
|
|
117
|
+
style={expanded ? { visibility: 'hidden' } : undefined}
|
|
118
|
+
>
|
|
119
|
+
<iframe
|
|
120
|
+
ref={iframeRef}
|
|
121
|
+
src={embedUrl}
|
|
122
|
+
className={styles.iframe}
|
|
123
|
+
title={`Figma ${typeLabel}: ${title}`}
|
|
124
|
+
allowFullScreen
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
{!interactive && !expanded && (
|
|
128
|
+
<div
|
|
129
|
+
className={styles.dragOverlay}
|
|
130
|
+
onDoubleClick={enterInteractive}
|
|
131
|
+
/>
|
|
132
|
+
)}
|
|
133
|
+
</>
|
|
134
|
+
) : (
|
|
135
|
+
<div className={styles.iframeContainer} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
136
|
+
<p style={{ color: 'var(--fgColor-muted, #656d76)', fontSize: 14, fontStyle: 'italic' }}>
|
|
137
|
+
No Figma URL
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
{resizable && (
|
|
143
|
+
<div
|
|
144
|
+
className={styles.resizeHandle}
|
|
145
|
+
onMouseDown={(e) => {
|
|
146
|
+
e.stopPropagation()
|
|
147
|
+
e.preventDefault()
|
|
148
|
+
const startX = e.clientX
|
|
149
|
+
const startY = e.clientY
|
|
150
|
+
const startW = width
|
|
151
|
+
const startH = height
|
|
152
|
+
function onMove(ev) {
|
|
153
|
+
const newW = Math.max(200, startW + ev.clientX - startX)
|
|
154
|
+
const newH = Math.max(150, startH + ev.clientY - startY)
|
|
155
|
+
onUpdate?.({ width: newW, height: newH })
|
|
156
|
+
}
|
|
157
|
+
function onUp() {
|
|
158
|
+
document.removeEventListener('mousemove', onMove)
|
|
159
|
+
document.removeEventListener('mouseup', onUp)
|
|
160
|
+
}
|
|
161
|
+
document.addEventListener('mousemove', onMove)
|
|
162
|
+
document.addEventListener('mouseup', onUp)
|
|
163
|
+
}}
|
|
164
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
165
|
+
/>
|
|
166
|
+
)}
|
|
167
|
+
</WidgetWrapper>
|
|
168
|
+
{createPortal(
|
|
169
|
+
<div
|
|
170
|
+
className={styles.expandBackdrop}
|
|
171
|
+
style={expanded && embedUrl ? undefined : { display: 'none' }}
|
|
172
|
+
onClick={() => setExpanded(false)}
|
|
173
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
174
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
175
|
+
onWheel={(e) => e.stopPropagation()}
|
|
176
|
+
>
|
|
177
|
+
<div
|
|
178
|
+
ref={modalContainerRef}
|
|
179
|
+
className={styles.expandContainer}
|
|
180
|
+
onClick={(e) => e.stopPropagation()}
|
|
181
|
+
>
|
|
182
|
+
{/* iframe is reparented here via useEffect */}
|
|
183
|
+
<button
|
|
184
|
+
className={styles.expandClose}
|
|
185
|
+
onClick={() => setExpanded(false)}
|
|
186
|
+
aria-label="Close expanded view"
|
|
187
|
+
autoFocus
|
|
188
|
+
>✕</button>
|
|
189
|
+
</div>
|
|
190
|
+
</div>,
|
|
191
|
+
document.body
|
|
192
|
+
)}
|
|
193
|
+
</>
|
|
194
|
+
)
|
|
195
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
.figmaLogo {
|
|
27
|
+
width: 14px;
|
|
28
|
+
height: 14px;
|
|
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); /* subtract header height */
|
|
40
|
+
overflow: hidden;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.iframe {
|
|
44
|
+
width: 100%;
|
|
45
|
+
height: calc(100% + 24px); /* clip Figma's built-in bottom toolbar */
|
|
46
|
+
border: none;
|
|
47
|
+
display: block;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.dragOverlay {
|
|
51
|
+
position: absolute;
|
|
52
|
+
inset: 0;
|
|
53
|
+
z-index: 1;
|
|
54
|
+
cursor: grab;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.resizeHandle {
|
|
58
|
+
position: absolute;
|
|
59
|
+
bottom: 0;
|
|
60
|
+
right: 0;
|
|
61
|
+
width: 16px;
|
|
62
|
+
height: 16px;
|
|
63
|
+
cursor: nwse-resize;
|
|
64
|
+
background: linear-gradient(
|
|
65
|
+
135deg,
|
|
66
|
+
transparent 40%,
|
|
67
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 40%,
|
|
68
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 50%,
|
|
69
|
+
transparent 50%,
|
|
70
|
+
transparent 65%,
|
|
71
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 65%,
|
|
72
|
+
var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 75%,
|
|
73
|
+
transparent 75%
|
|
74
|
+
);
|
|
75
|
+
opacity: 0;
|
|
76
|
+
transition: opacity 150ms;
|
|
77
|
+
z-index: 2;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.embed:hover ~ .resizeHandle,
|
|
81
|
+
.resizeHandle:hover {
|
|
82
|
+
opacity: 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* Expand modal — fullscreen overlay for expanded iframe */
|
|
86
|
+
.expandBackdrop {
|
|
87
|
+
position: fixed;
|
|
88
|
+
inset: 0;
|
|
89
|
+
z-index: 100000;
|
|
90
|
+
background: rgba(0, 0, 0, 0.8);
|
|
91
|
+
display: flex;
|
|
92
|
+
align-items: center;
|
|
93
|
+
justify-content: center;
|
|
94
|
+
animation: expandFadeIn 0.15s ease;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@keyframes expandFadeIn {
|
|
98
|
+
from { opacity: 0; }
|
|
99
|
+
to { opacity: 1; }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.expandContainer {
|
|
103
|
+
width: 90vw;
|
|
104
|
+
height: 90vh;
|
|
105
|
+
position: relative;
|
|
106
|
+
border-radius: 12px;
|
|
107
|
+
overflow: hidden;
|
|
108
|
+
background: var(--bgColor-default, #ffffff);
|
|
109
|
+
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
|
|
110
|
+
animation: expandScaleIn 0.2s ease;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@keyframes expandScaleIn {
|
|
114
|
+
from { transform: scale(0.95); opacity: 0; }
|
|
115
|
+
to { transform: scale(1); opacity: 1; }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.expandIframe {
|
|
119
|
+
border: none;
|
|
120
|
+
display: block;
|
|
121
|
+
width: 100%;
|
|
122
|
+
height: 100%;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.expandClose {
|
|
126
|
+
all: unset;
|
|
127
|
+
cursor: pointer;
|
|
128
|
+
position: absolute;
|
|
129
|
+
top: 12px;
|
|
130
|
+
right: 12px;
|
|
131
|
+
width: 32px;
|
|
132
|
+
height: 32px;
|
|
133
|
+
display: flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
justify-content: center;
|
|
136
|
+
border-radius: 8px;
|
|
137
|
+
background: rgba(0, 0, 0, 0.5);
|
|
138
|
+
color: #ffffff;
|
|
139
|
+
font-size: 16px;
|
|
140
|
+
z-index: 1;
|
|
141
|
+
transition: background 100ms;
|
|
142
|
+
backdrop-filter: blur(4px);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.expandClose:hover {
|
|
146
|
+
background: rgba(0, 0, 0, 0.7);
|
|
147
|
+
}
|