@dfosco/storyboard-react 3.11.0-beta.6 → 3.11.0-beta.8
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/canvas/CanvasPage.bridge.test.jsx +1 -0
- package/src/canvas/CanvasPage.jsx +173 -22
- package/src/canvas/CanvasPage.multiselect.test.jsx +260 -0
- package/src/canvas/useCanvas.js +2 -1
- package/src/canvas/widgets/ComponentWidget.jsx +9 -7
- package/src/canvas/widgets/FigmaEmbed.jsx +115 -26
- package/src/canvas/widgets/FigmaEmbed.module.css +65 -1
- package/src/canvas/widgets/ImageWidget.jsx +9 -7
- package/src/canvas/widgets/PrototypeEmbed.jsx +112 -25
- package/src/canvas/widgets/PrototypeEmbed.module.css +65 -1
- package/src/canvas/widgets/StickyNote.jsx +9 -7
- package/src/canvas/widgets/StickyNote.test.jsx +10 -4
- package/src/canvas/widgets/WidgetChrome.jsx +22 -18
- package/src/canvas/widgets/WidgetChrome.module.css +13 -6
- package/src/canvas/widgets/widgetConfig.js +20 -1
- package/src/canvas/widgets/widgetConfig.test.js +46 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { forwardRef, useImperativeHandle, useMemo, useCallback, useState } from 'react'
|
|
1
|
+
import { forwardRef, useImperativeHandle, useMemo, useCallback, useState, useEffect, useRef } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
2
3
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
3
4
|
import { readProp } from './widgetProps.js'
|
|
4
5
|
import { schemas } from './widgetConfig.js'
|
|
@@ -22,12 +23,17 @@ function FigmaLogo() {
|
|
|
22
23
|
|
|
23
24
|
const TYPE_LABELS = { board: 'Board', design: 'Design', proto: 'Prototype' }
|
|
24
25
|
|
|
25
|
-
export default forwardRef(function FigmaEmbed({ props, onUpdate }, ref) {
|
|
26
|
+
export default forwardRef(function FigmaEmbed({ props, onUpdate, resizable }, ref) {
|
|
26
27
|
const url = readProp(props, 'url', figmaEmbedSchema)
|
|
27
28
|
const width = readProp(props, 'width', figmaEmbedSchema)
|
|
28
29
|
const height = readProp(props, 'height', figmaEmbedSchema)
|
|
29
30
|
|
|
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)
|
|
31
37
|
|
|
32
38
|
// Validate URL at render time — only embed known Figma URLs
|
|
33
39
|
const isValid = useMemo(() => isFigmaUrl(url), [url])
|
|
@@ -38,15 +44,65 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate }, ref) {
|
|
|
38
44
|
|
|
39
45
|
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
40
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
|
+
|
|
41
94
|
useImperativeHandle(ref, () => ({
|
|
42
95
|
handleAction(actionId) {
|
|
43
96
|
if (actionId === 'open-external') {
|
|
44
97
|
if (url) window.open(url, '_blank', 'noopener')
|
|
98
|
+
} else if (actionId === 'expand') {
|
|
99
|
+
setExpanded(true)
|
|
45
100
|
}
|
|
46
101
|
},
|
|
47
102
|
}), [url])
|
|
48
103
|
|
|
49
104
|
return (
|
|
105
|
+
<>
|
|
50
106
|
<WidgetWrapper>
|
|
51
107
|
<div className={styles.embed} style={{ width, height }}>
|
|
52
108
|
<div className={styles.header}>
|
|
@@ -55,15 +111,20 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate }, ref) {
|
|
|
55
111
|
</div>
|
|
56
112
|
{embedUrl ? (
|
|
57
113
|
<>
|
|
58
|
-
<div
|
|
114
|
+
<div
|
|
115
|
+
ref={inlineContainerRef}
|
|
116
|
+
className={styles.iframeContainer}
|
|
117
|
+
style={expanded ? { visibility: 'hidden' } : undefined}
|
|
118
|
+
>
|
|
59
119
|
<iframe
|
|
120
|
+
ref={iframeRef}
|
|
60
121
|
src={embedUrl}
|
|
61
122
|
className={styles.iframe}
|
|
62
123
|
title={`Figma ${typeLabel}: ${title}`}
|
|
63
124
|
allowFullScreen
|
|
64
125
|
/>
|
|
65
126
|
</div>
|
|
66
|
-
{!interactive && (
|
|
127
|
+
{!interactive && !expanded && (
|
|
67
128
|
<div
|
|
68
129
|
className={styles.dragOverlay}
|
|
69
130
|
onDoubleClick={enterInteractive}
|
|
@@ -78,29 +139,57 @@ export default forwardRef(function FigmaEmbed({ props, onUpdate }, ref) {
|
|
|
78
139
|
</div>
|
|
79
140
|
)}
|
|
80
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(
|
|
81
169
|
<div
|
|
82
|
-
className={styles.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
e.preventDefault()
|
|
86
|
-
const startX = e.clientX
|
|
87
|
-
const startY = e.clientY
|
|
88
|
-
const startW = width
|
|
89
|
-
const startH = height
|
|
90
|
-
function onMove(ev) {
|
|
91
|
-
const newW = Math.max(200, startW + ev.clientX - startX)
|
|
92
|
-
const newH = Math.max(150, startH + ev.clientY - startY)
|
|
93
|
-
onUpdate?.({ width: newW, height: newH })
|
|
94
|
-
}
|
|
95
|
-
function onUp() {
|
|
96
|
-
document.removeEventListener('mousemove', onMove)
|
|
97
|
-
document.removeEventListener('mouseup', onUp)
|
|
98
|
-
}
|
|
99
|
-
document.addEventListener('mousemove', onMove)
|
|
100
|
-
document.addEventListener('mouseup', onUp)
|
|
101
|
-
}}
|
|
170
|
+
className={styles.expandBackdrop}
|
|
171
|
+
style={expanded && embedUrl ? undefined : { display: 'none' }}
|
|
172
|
+
onClick={() => setExpanded(false)}
|
|
102
173
|
onPointerDown={(e) => e.stopPropagation()}
|
|
103
|
-
|
|
104
|
-
|
|
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
|
+
</>
|
|
105
194
|
)
|
|
106
195
|
})
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
|
|
37
37
|
.iframeContainer {
|
|
38
38
|
width: 100%;
|
|
39
|
-
height: calc(100% -
|
|
39
|
+
height: calc(100% - 10px); /* subtract header height */
|
|
40
40
|
overflow: hidden;
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -81,3 +81,67 @@
|
|
|
81
81
|
.resizeHandle:hover {
|
|
82
82
|
opacity: 1;
|
|
83
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
|
+
}
|
|
@@ -18,7 +18,7 @@ function getImageUrl(src) {
|
|
|
18
18
|
* Canvas widget that displays a pasted image.
|
|
19
19
|
* Supports aspect-ratio locked resize and privacy toggle.
|
|
20
20
|
*/
|
|
21
|
-
const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate }, ref) {
|
|
21
|
+
const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate, resizable }, ref) {
|
|
22
22
|
const containerRef = useRef(null)
|
|
23
23
|
const [naturalRatio, setNaturalRatio] = useState(null)
|
|
24
24
|
|
|
@@ -99,12 +99,14 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate }, ref) {
|
|
|
99
99
|
</span>
|
|
100
100
|
)}
|
|
101
101
|
</div>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
102
|
+
{resizable && (
|
|
103
|
+
<ResizeHandle
|
|
104
|
+
targetRef={containerRef}
|
|
105
|
+
minWidth={100}
|
|
106
|
+
minHeight={60}
|
|
107
|
+
onResize={(w) => handleResize(w)}
|
|
108
|
+
/>
|
|
109
|
+
)}
|
|
108
110
|
</div>
|
|
109
111
|
</WidgetWrapper>
|
|
110
112
|
)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
2
3
|
import { buildPrototypeIndex } from '@dfosco/storyboard-core'
|
|
3
4
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
4
5
|
import { readProp, prototypeEmbedSchema } from './widgetProps.js'
|
|
@@ -28,7 +29,7 @@ function resolveCanvasThemeFromStorage() {
|
|
|
28
29
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
|
|
32
|
+
export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }, ref) {
|
|
32
33
|
const src = readProp(props, 'src', prototypeEmbedSchema)
|
|
33
34
|
const width = readProp(props, 'width', prototypeEmbedSchema)
|
|
34
35
|
const height = readProp(props, 'height', prototypeEmbedSchema)
|
|
@@ -51,12 +52,15 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
|
|
|
51
52
|
|
|
52
53
|
const [editing, setEditing] = useState(false)
|
|
53
54
|
const [interactive, setInteractive] = useState(false)
|
|
55
|
+
const [expanded, setExpanded] = useState(false)
|
|
54
56
|
const [filter, setFilter] = useState('')
|
|
55
57
|
const [canvasTheme, setCanvasTheme] = useState(() => resolveCanvasThemeFromStorage())
|
|
56
58
|
const inputRef = useRef(null)
|
|
57
59
|
const filterRef = useRef(null)
|
|
58
60
|
const embedRef = useRef(null)
|
|
59
61
|
const iframeRef = useRef(null)
|
|
62
|
+
const inlineContainerRef = useRef(null)
|
|
63
|
+
const modalContainerRef = useRef(null)
|
|
60
64
|
|
|
61
65
|
const iframeSrc = useMemo(() => {
|
|
62
66
|
if (!rawSrc) return ''
|
|
@@ -178,6 +182,54 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
|
|
|
178
182
|
return () => document.removeEventListener('storyboard:theme:changed', readToolbarTheme)
|
|
179
183
|
}, [])
|
|
180
184
|
|
|
185
|
+
// Close expanded modal on Escape
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (!expanded) return
|
|
188
|
+
function handleKeyDown(e) {
|
|
189
|
+
if (e.key === 'Escape') {
|
|
190
|
+
e.stopPropagation()
|
|
191
|
+
setExpanded(false)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
document.addEventListener('keydown', handleKeyDown, true)
|
|
195
|
+
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
|
196
|
+
}, [expanded])
|
|
197
|
+
|
|
198
|
+
// Reparent iframe DOM node between inline container and modal.
|
|
199
|
+
// Uses moveBefore() (Chrome 133+) which preserves the iframe's
|
|
200
|
+
// browsing context — no reload. Falls back to appendChild which
|
|
201
|
+
// will reload but still works functionally.
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
const iframe = iframeRef.current
|
|
204
|
+
if (!iframe) return
|
|
205
|
+
|
|
206
|
+
if (expanded && modalContainerRef.current) {
|
|
207
|
+
iframe._savedClassName = iframe.className
|
|
208
|
+
iframe._savedStyle = iframe.getAttribute('style') || ''
|
|
209
|
+
iframe.className = styles.expandIframe
|
|
210
|
+
iframe.removeAttribute('style')
|
|
211
|
+
const target = modalContainerRef.current
|
|
212
|
+
if (target.moveBefore) {
|
|
213
|
+
target.moveBefore(iframe, target.firstChild)
|
|
214
|
+
} else {
|
|
215
|
+
target.prepend(iframe)
|
|
216
|
+
}
|
|
217
|
+
} else if (!expanded && inlineContainerRef.current) {
|
|
218
|
+
if (iframe._savedClassName !== undefined) {
|
|
219
|
+
iframe.className = iframe._savedClassName
|
|
220
|
+
iframe.setAttribute('style', iframe._savedStyle)
|
|
221
|
+
delete iframe._savedClassName
|
|
222
|
+
delete iframe._savedStyle
|
|
223
|
+
}
|
|
224
|
+
const target = inlineContainerRef.current
|
|
225
|
+
if (target.moveBefore) {
|
|
226
|
+
target.moveBefore(iframe, null)
|
|
227
|
+
} else {
|
|
228
|
+
target.appendChild(iframe)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}, [expanded])
|
|
232
|
+
|
|
181
233
|
// Listen for navigation events from the embedded prototype iframe
|
|
182
234
|
useEffect(() => {
|
|
183
235
|
function handleMessage(e) {
|
|
@@ -202,6 +254,8 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
|
|
|
202
254
|
handleAction(actionId) {
|
|
203
255
|
if (actionId === 'edit') {
|
|
204
256
|
setEditing(true)
|
|
257
|
+
} else if (actionId === 'expand') {
|
|
258
|
+
setExpanded(true)
|
|
205
259
|
} else if (actionId === 'open-external') {
|
|
206
260
|
if (rawSrc) window.open(rawSrc, '_blank', 'noopener')
|
|
207
261
|
} else if (actionId === 'zoom-in') {
|
|
@@ -234,6 +288,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
|
|
|
234
288
|
}
|
|
235
289
|
|
|
236
290
|
return (
|
|
291
|
+
<>
|
|
237
292
|
<WidgetWrapper>
|
|
238
293
|
<div
|
|
239
294
|
ref={embedRef}
|
|
@@ -323,7 +378,11 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
|
|
|
323
378
|
</div>
|
|
324
379
|
) : iframeSrc ? (
|
|
325
380
|
<>
|
|
326
|
-
<div
|
|
381
|
+
<div
|
|
382
|
+
ref={inlineContainerRef}
|
|
383
|
+
className={styles.iframeContainer}
|
|
384
|
+
style={expanded ? { visibility: 'hidden' } : undefined}
|
|
385
|
+
>
|
|
327
386
|
<iframe
|
|
328
387
|
ref={iframeRef}
|
|
329
388
|
src={iframeSrc}
|
|
@@ -338,7 +397,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
|
|
|
338
397
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
|
|
339
398
|
/>
|
|
340
399
|
</div>
|
|
341
|
-
{!interactive && (
|
|
400
|
+
{!interactive && !expanded && (
|
|
342
401
|
<div
|
|
343
402
|
className={styles.dragOverlay}
|
|
344
403
|
onDoubleClick={enterInteractive}
|
|
@@ -357,29 +416,57 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate }, ref) {
|
|
|
357
416
|
</div>
|
|
358
417
|
)}
|
|
359
418
|
</div>
|
|
419
|
+
{resizable && (
|
|
420
|
+
<div
|
|
421
|
+
className={styles.resizeHandle}
|
|
422
|
+
onMouseDown={(e) => {
|
|
423
|
+
e.stopPropagation()
|
|
424
|
+
e.preventDefault()
|
|
425
|
+
const startX = e.clientX
|
|
426
|
+
const startY = e.clientY
|
|
427
|
+
const startW = width
|
|
428
|
+
const startH = height
|
|
429
|
+
function onMove(ev) {
|
|
430
|
+
const newW = Math.max(200, startW + ev.clientX - startX)
|
|
431
|
+
const newH = Math.max(150, startH + ev.clientY - startY)
|
|
432
|
+
onUpdate?.({ width: newW, height: newH })
|
|
433
|
+
}
|
|
434
|
+
function onUp() {
|
|
435
|
+
document.removeEventListener('mousemove', onMove)
|
|
436
|
+
document.removeEventListener('mouseup', onUp)
|
|
437
|
+
}
|
|
438
|
+
document.addEventListener('mousemove', onMove)
|
|
439
|
+
document.addEventListener('mouseup', onUp)
|
|
440
|
+
}}
|
|
441
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
442
|
+
/>
|
|
443
|
+
)}
|
|
444
|
+
</WidgetWrapper>
|
|
445
|
+
{createPortal(
|
|
360
446
|
<div
|
|
361
|
-
className={styles.
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
e.preventDefault()
|
|
365
|
-
const startX = e.clientX
|
|
366
|
-
const startY = e.clientY
|
|
367
|
-
const startW = width
|
|
368
|
-
const startH = height
|
|
369
|
-
function onMove(ev) {
|
|
370
|
-
const newW = Math.max(200, startW + ev.clientX - startX)
|
|
371
|
-
const newH = Math.max(150, startH + ev.clientY - startY)
|
|
372
|
-
onUpdate?.({ width: newW, height: newH })
|
|
373
|
-
}
|
|
374
|
-
function onUp() {
|
|
375
|
-
document.removeEventListener('mousemove', onMove)
|
|
376
|
-
document.removeEventListener('mouseup', onUp)
|
|
377
|
-
}
|
|
378
|
-
document.addEventListener('mousemove', onMove)
|
|
379
|
-
document.addEventListener('mouseup', onUp)
|
|
380
|
-
}}
|
|
447
|
+
className={styles.expandBackdrop}
|
|
448
|
+
style={expanded ? undefined : { display: 'none' }}
|
|
449
|
+
onClick={() => setExpanded(false)}
|
|
381
450
|
onPointerDown={(e) => e.stopPropagation()}
|
|
382
|
-
|
|
383
|
-
|
|
451
|
+
onKeyDown={(e) => e.stopPropagation()}
|
|
452
|
+
onWheel={(e) => e.stopPropagation()}
|
|
453
|
+
>
|
|
454
|
+
<div
|
|
455
|
+
ref={modalContainerRef}
|
|
456
|
+
className={styles.expandContainer}
|
|
457
|
+
onClick={(e) => e.stopPropagation()}
|
|
458
|
+
>
|
|
459
|
+
{/* iframe is reparented here via useEffect */}
|
|
460
|
+
<button
|
|
461
|
+
className={styles.expandClose}
|
|
462
|
+
onClick={() => setExpanded(false)}
|
|
463
|
+
aria-label="Close expanded view"
|
|
464
|
+
autoFocus
|
|
465
|
+
>✕</button>
|
|
466
|
+
</div>
|
|
467
|
+
</div>,
|
|
468
|
+
document.body
|
|
469
|
+
)}
|
|
470
|
+
</>
|
|
384
471
|
)
|
|
385
472
|
})
|
|
@@ -150,7 +150,7 @@
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
.pickerItem:focus-visible {
|
|
153
|
-
outline:
|
|
153
|
+
outline: 4px solid var(--bgColor-accent-emphasis, #2f81f7);
|
|
154
154
|
outline-offset: -2px;
|
|
155
155
|
}
|
|
156
156
|
|
|
@@ -326,3 +326,67 @@
|
|
|
326
326
|
border-right: 1.5px solid var(--trigger-border, var(--borderColor-muted, #d0d7de));
|
|
327
327
|
user-select: none;
|
|
328
328
|
}
|
|
329
|
+
|
|
330
|
+
/* Expand modal — fullscreen overlay for expanded iframe */
|
|
331
|
+
.expandBackdrop {
|
|
332
|
+
position: fixed;
|
|
333
|
+
inset: 0;
|
|
334
|
+
z-index: 100000;
|
|
335
|
+
background: rgba(0, 0, 0, 0.8);
|
|
336
|
+
display: flex;
|
|
337
|
+
align-items: center;
|
|
338
|
+
justify-content: center;
|
|
339
|
+
animation: expandFadeIn 0.15s ease;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
@keyframes expandFadeIn {
|
|
343
|
+
from { opacity: 0; }
|
|
344
|
+
to { opacity: 1; }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
.expandContainer {
|
|
348
|
+
width: 90vw;
|
|
349
|
+
height: 90vh;
|
|
350
|
+
position: relative;
|
|
351
|
+
border-radius: 12px;
|
|
352
|
+
overflow: hidden;
|
|
353
|
+
background: var(--bgColor-default, #ffffff);
|
|
354
|
+
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
|
|
355
|
+
animation: expandScaleIn 0.2s ease;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
@keyframes expandScaleIn {
|
|
359
|
+
from { transform: scale(0.95); opacity: 0; }
|
|
360
|
+
to { transform: scale(1); opacity: 1; }
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.expandIframe {
|
|
364
|
+
border: none;
|
|
365
|
+
display: block;
|
|
366
|
+
width: 100%;
|
|
367
|
+
height: 100%;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.expandClose {
|
|
371
|
+
all: unset;
|
|
372
|
+
cursor: pointer;
|
|
373
|
+
position: absolute;
|
|
374
|
+
top: 12px;
|
|
375
|
+
right: 12px;
|
|
376
|
+
width: 32px;
|
|
377
|
+
height: 32px;
|
|
378
|
+
display: flex;
|
|
379
|
+
align-items: center;
|
|
380
|
+
justify-content: center;
|
|
381
|
+
border-radius: 8px;
|
|
382
|
+
background: rgba(0, 0, 0, 0.5);
|
|
383
|
+
color: #ffffff;
|
|
384
|
+
font-size: 16px;
|
|
385
|
+
z-index: 1;
|
|
386
|
+
transition: background 100ms;
|
|
387
|
+
backdrop-filter: blur(4px);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.expandClose:hover {
|
|
391
|
+
background: rgba(0, 0, 0, 0.7);
|
|
392
|
+
}
|
|
@@ -12,7 +12,7 @@ const COLORS = {
|
|
|
12
12
|
orange: { bg: '#fff1e5', border: '#d18616', dot: '#e8a844' },
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
export default function StickyNote({ props, onUpdate }) {
|
|
15
|
+
export default function StickyNote({ props, onUpdate, resizable }) {
|
|
16
16
|
const text = readProp(props, 'text', stickyNoteSchema)
|
|
17
17
|
const color = readProp(props, 'color', stickyNoteSchema)
|
|
18
18
|
const width = readProp(props, 'width', stickyNoteSchema)
|
|
@@ -75,12 +75,14 @@ export default function StickyNote({ props, onUpdate }) {
|
|
|
75
75
|
placeholder="Type here…"
|
|
76
76
|
/>
|
|
77
77
|
)}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
78
|
+
{resizable && (
|
|
79
|
+
<ResizeHandle
|
|
80
|
+
targetRef={stickyRef}
|
|
81
|
+
minWidth={180}
|
|
82
|
+
minHeight={60}
|
|
83
|
+
onResize={handleResize}
|
|
84
|
+
/>
|
|
85
|
+
)}
|
|
84
86
|
</article>
|
|
85
87
|
</div>
|
|
86
88
|
)
|
|
@@ -49,16 +49,22 @@ describe('StickyNote', () => {
|
|
|
49
49
|
expect(sticky.style.height).toBe('200px')
|
|
50
50
|
})
|
|
51
51
|
|
|
52
|
-
it('renders a resize handle', () => {
|
|
53
|
-
const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} />)
|
|
52
|
+
it('renders a resize handle when resizable', () => {
|
|
53
|
+
const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} resizable />)
|
|
54
54
|
const handle = container.querySelector('[role="separator"]')
|
|
55
55
|
expect(handle).not.toBeNull()
|
|
56
56
|
})
|
|
57
57
|
|
|
58
|
+
it('does not render a resize handle when not resizable', () => {
|
|
59
|
+
const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} resizable={false} />)
|
|
60
|
+
const handle = container.querySelector('[role="separator"]')
|
|
61
|
+
expect(handle).toBeNull()
|
|
62
|
+
})
|
|
63
|
+
|
|
58
64
|
it('calls onUpdate with new dimensions on resize drag', () => {
|
|
59
65
|
const onUpdate = vi.fn()
|
|
60
66
|
const { container } = render(
|
|
61
|
-
<StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} />
|
|
67
|
+
<StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} resizable />
|
|
62
68
|
)
|
|
63
69
|
const handle = container.querySelector('[role="separator"]')
|
|
64
70
|
const sticky = container.querySelector('article')
|
|
@@ -78,7 +84,7 @@ describe('StickyNote', () => {
|
|
|
78
84
|
it('enforces minimum dimensions during resize', () => {
|
|
79
85
|
const onUpdate = vi.fn()
|
|
80
86
|
const { container } = render(
|
|
81
|
-
<StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} />
|
|
87
|
+
<StickyNote props={{ text: 'Hi', width: 200, height: 150 }} onUpdate={onUpdate} resizable />
|
|
82
88
|
)
|
|
83
89
|
const handle = container.querySelector('[role="separator"]')
|
|
84
90
|
const sticky = container.querySelector('article')
|