@dfosco/storyboard-react 3.11.1-beta.0 → 3.11.2

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.
Files changed (35) hide show
  1. package/package.json +3 -3
  2. package/src/Viewfinder.jsx +5 -3
  3. package/src/__mocks__/virtual-storyboard-data-index.js +1 -0
  4. package/src/canvas/CanvasControls.jsx +2 -59
  5. package/src/canvas/CanvasControls.module.css +0 -29
  6. package/src/canvas/CanvasPage.bridge.test.jsx +68 -42
  7. package/src/canvas/CanvasPage.jsx +801 -68
  8. package/src/canvas/CanvasPage.module.css +47 -2
  9. package/src/canvas/CanvasPage.multiselect.test.jsx +345 -0
  10. package/src/canvas/canvasApi.js +8 -0
  11. package/src/canvas/computeCanvasBounds.test.js +121 -0
  12. package/src/canvas/useCanvas.js +2 -1
  13. package/src/canvas/useUndoRedo.js +86 -0
  14. package/src/canvas/useUndoRedo.test.js +231 -0
  15. package/src/canvas/widgets/ComponentWidget.jsx +9 -7
  16. package/src/canvas/widgets/FigmaEmbed.jsx +195 -0
  17. package/src/canvas/widgets/FigmaEmbed.module.css +147 -0
  18. package/src/canvas/widgets/ImageWidget.jsx +115 -0
  19. package/src/canvas/widgets/ImageWidget.module.css +39 -0
  20. package/src/canvas/widgets/MarkdownBlock.jsx +25 -8
  21. package/src/canvas/widgets/MarkdownBlock.test.jsx +53 -0
  22. package/src/canvas/widgets/PrototypeEmbed.jsx +132 -26
  23. package/src/canvas/widgets/PrototypeEmbed.module.css +66 -2
  24. package/src/canvas/widgets/StickyNote.jsx +21 -16
  25. package/src/canvas/widgets/StickyNote.test.jsx +24 -4
  26. package/src/canvas/widgets/WidgetChrome.jsx +276 -50
  27. package/src/canvas/widgets/WidgetChrome.module.css +91 -10
  28. package/src/canvas/widgets/figmaUrl.js +118 -0
  29. package/src/canvas/widgets/figmaUrl.test.js +139 -0
  30. package/src/canvas/widgets/index.js +4 -0
  31. package/src/canvas/widgets/widgetConfig.js +74 -6
  32. package/src/canvas/widgets/widgetConfig.test.js +46 -0
  33. package/src/canvas/widgets/widgetProps.js +2 -0
  34. package/src/context.jsx +34 -4
  35. 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
- <ResizeHandle
55
- targetRef={containerRef}
56
- minWidth={100}
57
- minHeight={60}
58
- onResize={handleResize}
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
+ }