@dfosco/storyboard-react 4.0.0-beta.8 → 4.0.0
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 +6 -3
- package/src/AuthModal/AuthModal.jsx +134 -0
- package/src/AuthModal/AuthModal.module.css +221 -0
- package/src/BranchBar/BranchBar.jsx +56 -0
- package/src/BranchBar/BranchBar.module.css +230 -0
- package/src/BranchBar/useBranches.js +79 -0
- package/src/CommandPalette/CommandPalette.jsx +936 -0
- package/src/CommandPalette/CreateDialog.jsx +219 -0
- package/src/CommandPalette/command-palette.css +111 -0
- package/src/Icon.jsx +180 -0
- package/src/Viewfinder.jsx +1104 -57
- package/src/Viewfinder.module.css +1107 -149
- package/src/canvas/CanvasControls.jsx +51 -2
- package/src/canvas/CanvasControls.module.css +31 -0
- package/src/canvas/CanvasPage.bridge.test.jsx +142 -19
- package/src/canvas/CanvasPage.dragdrop.test.jsx +346 -0
- package/src/canvas/CanvasPage.jsx +807 -251
- package/src/canvas/CanvasPage.module.css +98 -50
- package/src/canvas/CanvasPage.multiselect.test.jsx +13 -11
- package/src/canvas/CanvasToolbar.jsx +2 -2
- package/src/canvas/MarqueeOverlay.jsx +20 -0
- package/src/canvas/PageSelector.jsx +239 -0
- package/src/canvas/PageSelector.module.css +165 -0
- package/src/canvas/PageSelector.test.jsx +104 -0
- package/src/canvas/canvasApi.js +22 -8
- package/src/canvas/canvasTheme.js +96 -52
- package/src/canvas/componentIsolate.jsx +33 -7
- package/src/canvas/useCanvas.js +9 -8
- package/src/canvas/useCanvas.test.js +4 -4
- package/src/canvas/useMarqueeSelect.js +187 -0
- package/src/canvas/useMarqueeSelect.test.js +78 -0
- package/src/canvas/widgets/CodePenEmbed.jsx +292 -0
- package/src/canvas/widgets/CodePenEmbed.module.css +161 -0
- package/src/canvas/widgets/ComponentWidget.jsx +42 -10
- package/src/canvas/widgets/ComponentWidget.module.css +6 -5
- package/src/canvas/widgets/FigmaEmbed.jsx +110 -24
- package/src/canvas/widgets/FigmaEmbed.module.css +21 -7
- package/src/canvas/widgets/LinkPreview.jsx +297 -11
- package/src/canvas/widgets/LinkPreview.module.css +386 -18
- package/src/canvas/widgets/LinkPreview.test.jsx +193 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +86 -5
- package/src/canvas/widgets/MarkdownBlock.module.css +64 -15
- package/src/canvas/widgets/PrototypeEmbed.jsx +96 -145
- 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 +277 -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 +2 -6
- 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 +138 -39
- 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/snapshotDisplay.test.jsx +259 -0
- package/src/canvas/widgets/widgetConfig.js +16 -5
- package/src/canvas/widgets/widgetConfig.test.js +34 -12
- package/src/context.jsx +145 -16
- package/src/hooks/useSceneData.js +4 -2
- package/src/hooks/useThemeState.js +61 -0
- package/src/hooks/useThemeState.test.js +66 -0
- package/src/index.js +10 -0
- package/src/story/StoryPage.jsx +117 -0
- package/src/story/StoryPage.module.css +18 -0
- package/src/vite/data-plugin.js +348 -66
- package/src/vite/data-plugin.test.js +405 -5
|
@@ -2,9 +2,22 @@ import { useState, useRef, useEffect, useCallback, useMemo, forwardRef, useImper
|
|
|
2
2
|
import { createPortal } from 'react-dom'
|
|
3
3
|
import { buildPrototypeIndex } from '@dfosco/storyboard-core'
|
|
4
4
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
5
|
+
import ResizeHandle from './ResizeHandle.jsx'
|
|
5
6
|
import { readProp, prototypeEmbedSchema } from './widgetProps.js'
|
|
6
7
|
import { getEmbedChromeVars } from './embedTheme.js'
|
|
8
|
+
import { useIframeDevLogs } from './iframeDevLogs.js'
|
|
7
9
|
import styles from './PrototypeEmbed.module.css'
|
|
10
|
+
import overlayStyles from './embedOverlay.module.css'
|
|
11
|
+
|
|
12
|
+
function CollageFrameIcon({ size = 36 }) {
|
|
13
|
+
return (
|
|
14
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
|
15
|
+
<path d="M19.4 20H4.6C4.26863 20 4 19.7314 4 19.4V4.6C4 4.26863 4.26863 4 4.6 4H19.4C19.7314 4 20 4.26863 20 4.6V19.4C20 19.7314 19.7314 20 19.4 20Z" />
|
|
16
|
+
<path d="M11 12V4" />
|
|
17
|
+
<path d="M4 12H20" />
|
|
18
|
+
</svg>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
8
21
|
|
|
9
22
|
function formatName(name) {
|
|
10
23
|
return name
|
|
@@ -18,18 +31,17 @@ function resolveCanvasThemeFromStorage() {
|
|
|
18
31
|
try {
|
|
19
32
|
const rawSync = localStorage.getItem('sb-theme-sync')
|
|
20
33
|
if (rawSync) sync = { ...sync, ...JSON.parse(rawSync) }
|
|
21
|
-
} catch {
|
|
22
|
-
// Ignore malformed sync settings
|
|
23
|
-
}
|
|
34
|
+
} catch { /* */ }
|
|
24
35
|
if (!sync.canvas) return 'light'
|
|
25
36
|
const attrTheme = document.documentElement.getAttribute('data-sb-canvas-theme')
|
|
26
37
|
if (attrTheme) return attrTheme
|
|
27
38
|
const stored = localStorage.getItem('sb-color-scheme') || 'system'
|
|
28
39
|
if (stored !== 'system') return stored
|
|
29
|
-
return window.matchMedia
|
|
40
|
+
return typeof window.matchMedia === 'function' &&
|
|
41
|
+
window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
|
30
42
|
}
|
|
31
43
|
|
|
32
|
-
export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }, ref) {
|
|
44
|
+
export default forwardRef(function PrototypeEmbed({ id: widgetId, props, onUpdate, resizable }, ref) {
|
|
33
45
|
const src = readProp(props, 'src', prototypeEmbedSchema)
|
|
34
46
|
const width = readProp(props, 'width', prototypeEmbedSchema)
|
|
35
47
|
const height = readProp(props, 'height', prototypeEmbedSchema)
|
|
@@ -41,7 +53,6 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
41
53
|
const rawSrc = useMemo(() => {
|
|
42
54
|
if (!src) return ''
|
|
43
55
|
if (/^https?:\/\//.test(src)) return src
|
|
44
|
-
// Strip stale branch prefixes from stored src (e.g. /branch--old-feat/Page)
|
|
45
56
|
const cleaned = src.replace(/^\/branch--[^/]+/, '')
|
|
46
57
|
if (baseSegment && cleaned.startsWith(basePath)) return cleaned
|
|
47
58
|
if (baseSegment && cleaned.startsWith(baseSegment)) return `/${cleaned}`
|
|
@@ -49,6 +60,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
49
60
|
}, [src, basePath, baseSegment])
|
|
50
61
|
|
|
51
62
|
const scale = zoom / 100
|
|
63
|
+
const isExternal = /^https?:\/\//.test(src || '')
|
|
52
64
|
|
|
53
65
|
const [editing, setEditing] = useState(false)
|
|
54
66
|
const [interactive, setInteractive] = useState(false)
|
|
@@ -64,30 +76,22 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
64
76
|
|
|
65
77
|
const iframeSrc = useMemo(() => {
|
|
66
78
|
if (!rawSrc) return ''
|
|
67
|
-
// External URLs are embedded as-is — storyboard query params only apply to local prototypes
|
|
68
79
|
if (/^https?:\/\//.test(rawSrc)) return rawSrc
|
|
69
80
|
const hashIdx = rawSrc.indexOf('#')
|
|
70
81
|
const base = hashIdx >= 0 ? rawSrc.slice(0, hashIdx) : rawSrc
|
|
71
82
|
const hash = hashIdx >= 0 ? rawSrc.slice(hashIdx) : ''
|
|
72
83
|
const sep = base.includes('?') ? '&' : '?'
|
|
73
|
-
return `${base}${sep}_sb_embed&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
|
|
84
|
+
return `${base}${sep}_sb_embed&_sb_hide_branch_bar&_sb_theme_target=prototype&_sb_canvas_theme=${canvasTheme}${hash}`
|
|
74
85
|
}, [rawSrc, canvasTheme])
|
|
75
86
|
|
|
76
|
-
// Build prototype index for the picker
|
|
77
87
|
const prototypeIndex = useMemo(() => {
|
|
78
|
-
try {
|
|
79
|
-
|
|
80
|
-
} catch {
|
|
81
|
-
return { folders: [], prototypes: [], globalFlows: [], sorted: { title: { prototypes: [], folders: [] } } }
|
|
82
|
-
}
|
|
88
|
+
try { return buildPrototypeIndex() }
|
|
89
|
+
catch { return { folders: [], prototypes: [], globalFlows: [], sorted: { title: { prototypes: [], folders: [] } } } }
|
|
83
90
|
}, [])
|
|
84
91
|
|
|
85
|
-
// Build grouped picker entries from the prototype index
|
|
86
92
|
const pickerGroups = useMemo(() => {
|
|
87
93
|
const groups = []
|
|
88
94
|
const idx = prototypeIndex
|
|
89
|
-
|
|
90
|
-
// Collect all prototypes (from folders first, then ungrouped)
|
|
91
95
|
const allProtos = []
|
|
92
96
|
for (const folder of (idx.sorted?.title?.folders || idx.folders || [])) {
|
|
93
97
|
for (const proto of folder.prototypes || []) {
|
|
@@ -97,45 +101,22 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
97
101
|
for (const proto of (idx.sorted?.title?.prototypes || idx.prototypes || [])) {
|
|
98
102
|
if (!proto.isExternal) allProtos.push(proto)
|
|
99
103
|
}
|
|
100
|
-
|
|
101
104
|
for (const proto of allProtos) {
|
|
102
105
|
if (proto.hideFlows && proto.flows.length === 1) {
|
|
103
|
-
groups.push({
|
|
104
|
-
label: proto.name,
|
|
105
|
-
items: [{ name: proto.name, route: proto.flows[0].route }],
|
|
106
|
-
})
|
|
106
|
+
groups.push({ label: proto.name, items: [{ name: proto.name, route: proto.flows[0].route }] })
|
|
107
107
|
} else if (proto.flows.length > 0) {
|
|
108
|
-
groups.push({
|
|
109
|
-
label: proto.name,
|
|
110
|
-
items: proto.flows.map((f) => ({
|
|
111
|
-
name: f.meta?.title || formatName(f.name),
|
|
112
|
-
route: f.route,
|
|
113
|
-
})),
|
|
114
|
-
})
|
|
108
|
+
groups.push({ label: proto.name, items: proto.flows.map((f) => ({ name: f.meta?.title || formatName(f.name), route: f.route })) })
|
|
115
109
|
} else {
|
|
116
|
-
groups.push({
|
|
117
|
-
label: proto.name,
|
|
118
|
-
items: [{ name: proto.name, route: `/${proto.dirName}` }],
|
|
119
|
-
})
|
|
110
|
+
groups.push({ label: proto.name, items: [{ name: proto.name, route: `/${proto.dirName}` }] })
|
|
120
111
|
}
|
|
121
112
|
}
|
|
122
|
-
|
|
123
|
-
// Global flows
|
|
124
113
|
const gf = idx.globalFlows || []
|
|
125
114
|
if (gf.length > 0) {
|
|
126
|
-
groups.push({
|
|
127
|
-
label: 'Other flows',
|
|
128
|
-
items: gf.map((f) => ({
|
|
129
|
-
name: f.meta?.title || formatName(f.name),
|
|
130
|
-
route: f.route,
|
|
131
|
-
})),
|
|
132
|
-
})
|
|
115
|
+
groups.push({ label: 'Other flows', items: gf.map((f) => ({ name: f.meta?.title || formatName(f.name), route: f.route })) })
|
|
133
116
|
}
|
|
134
|
-
|
|
135
117
|
return groups
|
|
136
118
|
}, [prototypeIndex])
|
|
137
119
|
|
|
138
|
-
// Filter groups by search text
|
|
139
120
|
const filteredGroups = useMemo(() => {
|
|
140
121
|
if (!filter) return pickerGroups
|
|
141
122
|
const q = filter.toLowerCase()
|
|
@@ -152,8 +133,30 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
152
133
|
.filter(Boolean)
|
|
153
134
|
}, [pickerGroups, filter])
|
|
154
135
|
|
|
136
|
+
const prototypeTitle = useMemo(() => {
|
|
137
|
+
if (!src) return label || 'Prototype'
|
|
138
|
+
const cleanSrc = src.replace(/^\/branch--[^/]+/, '')
|
|
139
|
+
for (const group of pickerGroups) {
|
|
140
|
+
for (const item of group.items) {
|
|
141
|
+
const cleanRoute = item.route.replace(/^\/branch--[^/]+/, '')
|
|
142
|
+
if (cleanRoute === cleanSrc) {
|
|
143
|
+
// If the flow name matches the group name, just show the name
|
|
144
|
+
if (item.name === group.label) return group.label
|
|
145
|
+
return `${group.label} · ${item.name}`
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return label || 'Prototype'
|
|
150
|
+
}, [src, label, pickerGroups])
|
|
151
|
+
|
|
155
152
|
const hasPicker = pickerGroups.length > 0
|
|
156
153
|
|
|
154
|
+
useIframeDevLogs({
|
|
155
|
+
widget: 'PrototypeEmbed',
|
|
156
|
+
loaded: Boolean(iframeSrc && interactive),
|
|
157
|
+
src: iframeSrc,
|
|
158
|
+
})
|
|
159
|
+
|
|
157
160
|
useEffect(() => {
|
|
158
161
|
if (editing && hasPicker && filterRef.current) {
|
|
159
162
|
filterRef.current.focus()
|
|
@@ -165,15 +168,17 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
165
168
|
|
|
166
169
|
// Exit interactive mode when clicking outside the embed
|
|
167
170
|
useEffect(() => {
|
|
168
|
-
if (!interactive) return
|
|
171
|
+
if (!interactive || expanded) return
|
|
169
172
|
function handlePointerDown(e) {
|
|
170
173
|
if (embedRef.current && !embedRef.current.contains(e.target)) {
|
|
174
|
+
const chromeEl = e.target.closest(`[data-widget-id="${widgetId}"]`)
|
|
175
|
+
if (chromeEl) return
|
|
171
176
|
setInteractive(false)
|
|
172
177
|
}
|
|
173
178
|
}
|
|
174
179
|
document.addEventListener('pointerdown', handlePointerDown)
|
|
175
180
|
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
176
|
-
}, [interactive])
|
|
181
|
+
}, [interactive, expanded, widgetId])
|
|
177
182
|
|
|
178
183
|
useEffect(() => {
|
|
179
184
|
function readToolbarTheme() {
|
|
@@ -197,25 +202,18 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
197
202
|
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
|
198
203
|
}, [expanded])
|
|
199
204
|
|
|
200
|
-
// Reparent iframe
|
|
201
|
-
// Uses moveBefore() (Chrome 133+) which preserves the iframe's
|
|
202
|
-
// browsing context — no reload. Falls back to appendChild which
|
|
203
|
-
// will reload but still works functionally.
|
|
205
|
+
// Reparent iframe between inline and modal
|
|
204
206
|
useEffect(() => {
|
|
205
207
|
const iframe = iframeRef.current
|
|
206
208
|
if (!iframe) return
|
|
207
|
-
|
|
208
209
|
if (expanded && modalContainerRef.current) {
|
|
209
210
|
iframe._savedClassName = iframe.className
|
|
210
211
|
iframe._savedStyle = iframe.getAttribute('style') || ''
|
|
211
212
|
iframe.className = styles.expandIframe
|
|
212
213
|
iframe.removeAttribute('style')
|
|
213
214
|
const target = modalContainerRef.current
|
|
214
|
-
if (target.moveBefore)
|
|
215
|
-
|
|
216
|
-
} else {
|
|
217
|
-
target.prepend(iframe)
|
|
218
|
-
}
|
|
215
|
+
if (target.moveBefore) target.moveBefore(iframe, target.firstChild)
|
|
216
|
+
else target.prepend(iframe)
|
|
219
217
|
} else if (!expanded && inlineContainerRef.current) {
|
|
220
218
|
if (iframe._savedClassName !== undefined) {
|
|
221
219
|
iframe.className = iframe._savedClassName
|
|
@@ -224,11 +222,8 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
224
222
|
delete iframe._savedStyle
|
|
225
223
|
}
|
|
226
224
|
const target = inlineContainerRef.current
|
|
227
|
-
if (target.moveBefore)
|
|
228
|
-
|
|
229
|
-
} else {
|
|
230
|
-
target.appendChild(iframe)
|
|
231
|
-
}
|
|
225
|
+
if (target.moveBefore) target.moveBefore(iframe, null)
|
|
226
|
+
else target.appendChild(iframe)
|
|
232
227
|
}
|
|
233
228
|
}, [expanded])
|
|
234
229
|
|
|
@@ -251,7 +246,6 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
251
246
|
|
|
252
247
|
const enterInteractive = useCallback(() => setInteractive(true), [])
|
|
253
248
|
|
|
254
|
-
// Expose imperative action handlers for WidgetChrome
|
|
255
249
|
useImperativeHandle(ref, () => ({
|
|
256
250
|
handleAction(actionId) {
|
|
257
251
|
if (actionId === 'edit') {
|
|
@@ -289,6 +283,10 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
289
283
|
setFilter('')
|
|
290
284
|
}
|
|
291
285
|
|
|
286
|
+
const handleResize = useCallback((w, h) => {
|
|
287
|
+
onUpdate?.({ width: w, height: h })
|
|
288
|
+
}, [onUpdate])
|
|
289
|
+
|
|
292
290
|
return (
|
|
293
291
|
<>
|
|
294
292
|
<WidgetWrapper>
|
|
@@ -297,6 +295,10 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
297
295
|
className={styles.embed}
|
|
298
296
|
style={{ width, height, ...chromeVars }}
|
|
299
297
|
>
|
|
298
|
+
<div className={styles.header}>
|
|
299
|
+
<span className={styles.headerIcon}><CollageFrameIcon size={16} /></span>
|
|
300
|
+
<span className={styles.headerTitle}>{prototypeTitle}</span>
|
|
301
|
+
</div>
|
|
300
302
|
{editing ? (
|
|
301
303
|
<div
|
|
302
304
|
className={styles.pickerPanel}
|
|
@@ -307,12 +309,7 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
307
309
|
<>
|
|
308
310
|
<div className={styles.pickerHeader}>
|
|
309
311
|
<span className={styles.urlLabel}>Pick a prototype</span>
|
|
310
|
-
<button
|
|
311
|
-
type="button"
|
|
312
|
-
className={styles.urlCancel}
|
|
313
|
-
onClick={handleCancelEdit}
|
|
314
|
-
aria-label="Cancel"
|
|
315
|
-
>✕</button>
|
|
312
|
+
<button type="button" className={styles.urlCancel} onClick={handleCancelEdit} aria-label="Cancel">✕</button>
|
|
316
313
|
</div>
|
|
317
314
|
<input
|
|
318
315
|
ref={filterRef}
|
|
@@ -327,23 +324,14 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
327
324
|
{filteredGroups.map((group) => (
|
|
328
325
|
<div key={group.label} className={styles.pickerGroup}>
|
|
329
326
|
{group.items.length === 1 && group.items[0].name === group.label ? (
|
|
330
|
-
<button
|
|
331
|
-
className={styles.pickerItem}
|
|
332
|
-
role="option"
|
|
333
|
-
onClick={() => handlePickRoute(group.items[0].route)}
|
|
334
|
-
>
|
|
327
|
+
<button className={styles.pickerItem} role="option" onClick={() => handlePickRoute(group.items[0].route)}>
|
|
335
328
|
{group.label}
|
|
336
329
|
</button>
|
|
337
330
|
) : (
|
|
338
331
|
<>
|
|
339
332
|
<div className={styles.pickerGroupLabel}>{group.label}</div>
|
|
340
333
|
{group.items.map((item) => (
|
|
341
|
-
<button
|
|
342
|
-
key={item.route}
|
|
343
|
-
className={styles.pickerItem}
|
|
344
|
-
role="option"
|
|
345
|
-
onClick={() => handlePickRoute(item.route)}
|
|
346
|
-
>
|
|
334
|
+
<button key={item.route} className={styles.pickerItem} role="option" onClick={() => handlePickRoute(item.route)}>
|
|
347
335
|
{item.name}
|
|
348
336
|
</button>
|
|
349
337
|
))}
|
|
@@ -351,30 +339,17 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
351
339
|
)}
|
|
352
340
|
</div>
|
|
353
341
|
))}
|
|
354
|
-
{filteredGroups.length === 0 &&
|
|
355
|
-
<div className={styles.pickerEmpty}>No matches</div>
|
|
356
|
-
)}
|
|
342
|
+
{filteredGroups.length === 0 && <div className={styles.pickerEmpty}>No matches</div>}
|
|
357
343
|
</div>
|
|
358
344
|
<div className={styles.pickerDivider} />
|
|
359
345
|
</>
|
|
360
346
|
)}
|
|
361
347
|
<form className={styles.customUrlSection} onSubmit={handleSubmit}>
|
|
362
|
-
<label className={styles.urlLabel}>
|
|
363
|
-
|
|
364
|
-
</label>
|
|
365
|
-
<input
|
|
366
|
-
ref={inputRef}
|
|
367
|
-
className={styles.urlInput}
|
|
368
|
-
type="text"
|
|
369
|
-
defaultValue={src}
|
|
370
|
-
placeholder="/MyPrototype/page"
|
|
371
|
-
onKeyDown={(e) => { if (e.key === 'Escape') handleCancelEdit() }}
|
|
372
|
-
/>
|
|
348
|
+
<label className={styles.urlLabel}>{hasPicker ? 'Or enter a custom URL' : 'Prototype URL path'}</label>
|
|
349
|
+
<input ref={inputRef} className={styles.urlInput} type="text" defaultValue={src} placeholder="/MyPrototype/page" onKeyDown={(e) => { if (e.key === 'Escape') handleCancelEdit() }} />
|
|
373
350
|
<div className={styles.urlActions}>
|
|
374
351
|
<button type="submit" className={styles.urlSave}>Save</button>
|
|
375
|
-
{!hasPicker &&
|
|
376
|
-
<button type="button" className={styles.urlCancel} onClick={handleCancelEdit}>Cancel</button>
|
|
377
|
-
)}
|
|
352
|
+
{!hasPicker && <button type="button" className={styles.urlCancel} onClick={handleCancelEdit}>Cancel</button>}
|
|
378
353
|
</div>
|
|
379
354
|
</form>
|
|
380
355
|
</div>
|
|
@@ -395,54 +370,40 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
395
370
|
transform: `scale(${scale})`,
|
|
396
371
|
transformOrigin: '0 0',
|
|
397
372
|
}}
|
|
398
|
-
title={
|
|
373
|
+
title={`${prototypeTitle} prototype`}
|
|
399
374
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups"
|
|
400
375
|
/>
|
|
401
376
|
</div>
|
|
402
377
|
{!interactive && !expanded && (
|
|
403
378
|
<div
|
|
404
|
-
className={
|
|
405
|
-
|
|
406
|
-
|
|
379
|
+
className={overlayStyles.interactOverlay}
|
|
380
|
+
onClick={(e) => {
|
|
381
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
382
|
+
enterInteractive()
|
|
383
|
+
}}
|
|
384
|
+
role="button"
|
|
385
|
+
tabIndex={0}
|
|
386
|
+
onKeyDown={(e) => {
|
|
387
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
388
|
+
e.preventDefault()
|
|
389
|
+
e.stopPropagation()
|
|
390
|
+
enterInteractive()
|
|
391
|
+
}
|
|
392
|
+
}}
|
|
393
|
+
aria-label="Click to interact with prototype"
|
|
394
|
+
>
|
|
395
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
396
|
+
</div>
|
|
407
397
|
)}
|
|
408
398
|
</>
|
|
409
399
|
) : (
|
|
410
|
-
<div
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
role="button"
|
|
414
|
-
tabIndex={0}
|
|
415
|
-
onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
|
|
416
|
-
>
|
|
417
|
-
<p>Double-click to set prototype URL</p>
|
|
400
|
+
<div className={styles.empty} onClick={() => onUpdate && setEditing(true)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}>
|
|
401
|
+
<CollageFrameIcon size={36} />
|
|
402
|
+
<p>Click to set prototype URL</p>
|
|
418
403
|
</div>
|
|
419
404
|
)}
|
|
420
405
|
</div>
|
|
421
|
-
{resizable &&
|
|
422
|
-
<div
|
|
423
|
-
className={styles.resizeHandle}
|
|
424
|
-
onMouseDown={(e) => {
|
|
425
|
-
e.stopPropagation()
|
|
426
|
-
e.preventDefault()
|
|
427
|
-
const startX = e.clientX
|
|
428
|
-
const startY = e.clientY
|
|
429
|
-
const startW = width
|
|
430
|
-
const startH = height
|
|
431
|
-
function onMove(ev) {
|
|
432
|
-
const newW = Math.max(200, startW + ev.clientX - startX)
|
|
433
|
-
const newH = Math.max(150, startH + ev.clientY - startY)
|
|
434
|
-
onUpdate?.({ width: newW, height: newH })
|
|
435
|
-
}
|
|
436
|
-
function onUp() {
|
|
437
|
-
document.removeEventListener('mousemove', onMove)
|
|
438
|
-
document.removeEventListener('mouseup', onUp)
|
|
439
|
-
}
|
|
440
|
-
document.addEventListener('mousemove', onMove)
|
|
441
|
-
document.addEventListener('mouseup', onUp)
|
|
442
|
-
}}
|
|
443
|
-
onPointerDown={(e) => e.stopPropagation()}
|
|
444
|
-
/>
|
|
445
|
-
)}
|
|
406
|
+
{resizable && <ResizeHandle targetRef={embedRef} width={width} height={height} onResize={handleResize} />}
|
|
446
407
|
</WidgetWrapper>
|
|
447
408
|
{createPortal(
|
|
448
409
|
<div
|
|
@@ -453,18 +414,8 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
453
414
|
onKeyDown={(e) => e.stopPropagation()}
|
|
454
415
|
onWheel={(e) => e.stopPropagation()}
|
|
455
416
|
>
|
|
456
|
-
<div
|
|
457
|
-
|
|
458
|
-
className={styles.expandContainer}
|
|
459
|
-
onClick={(e) => e.stopPropagation()}
|
|
460
|
-
>
|
|
461
|
-
{/* iframe is reparented here via useEffect */}
|
|
462
|
-
<button
|
|
463
|
-
className={styles.expandClose}
|
|
464
|
-
onClick={() => setExpanded(false)}
|
|
465
|
-
aria-label="Close expanded view"
|
|
466
|
-
autoFocus
|
|
467
|
-
>✕</button>
|
|
417
|
+
<div ref={modalContainerRef} className={styles.expandContainer} onClick={(e) => e.stopPropagation()}>
|
|
418
|
+
<button className={styles.expandClose} onClick={() => setExpanded(false)} aria-label="Close expanded view" autoFocus>✕</button>
|
|
468
419
|
</div>
|
|
469
420
|
</div>,
|
|
470
421
|
document.body
|
|
@@ -7,22 +7,92 @@
|
|
|
7
7
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
+
.header {
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: 6px;
|
|
14
|
+
padding: 10px 10px;
|
|
15
|
+
font-size: 12px;
|
|
16
|
+
font-weight: 500;
|
|
17
|
+
color: var(--fgColor-muted, #656d76);
|
|
18
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
19
|
+
border-bottom: 1px solid var(--borderColor-muted, #d8dee4);
|
|
20
|
+
white-space: nowrap;
|
|
21
|
+
overflow: hidden;
|
|
22
|
+
text-overflow: ellipsis;
|
|
23
|
+
user-select: none;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.headerIcon {
|
|
27
|
+
display: inline-flex;
|
|
28
|
+
flex-shrink: 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
.headerTitle {
|
|
32
|
+
overflow: hidden;
|
|
33
|
+
text-overflow: ellipsis;
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
.iframeContainer {
|
|
37
|
+
position: relative;
|
|
11
38
|
width: 100%;
|
|
12
|
-
height: 100
|
|
39
|
+
height: calc(100% - 37px);
|
|
13
40
|
overflow: hidden;
|
|
14
41
|
}
|
|
15
42
|
|
|
43
|
+
.placeholder {
|
|
44
|
+
position: absolute;
|
|
45
|
+
inset: 0;
|
|
46
|
+
display: flex;
|
|
47
|
+
flex-direction: column;
|
|
48
|
+
align-items: center;
|
|
49
|
+
justify-content: center;
|
|
50
|
+
gap: 8px;
|
|
51
|
+
color: var(--fgColor-muted, #656d76);
|
|
52
|
+
text-align: center;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.placeholderIcon {
|
|
56
|
+
width: 36px;
|
|
57
|
+
height: 36px;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.placeholderLabel {
|
|
61
|
+
font-size: 13px;
|
|
62
|
+
font-weight: 500;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.spinner {
|
|
66
|
+
width: 24px;
|
|
67
|
+
height: 24px;
|
|
68
|
+
border: 3px solid var(--borderColor-muted, #d0d7de);
|
|
69
|
+
border-top-color: var(--fgColor-accent, #2f81f7);
|
|
70
|
+
border-radius: 50%;
|
|
71
|
+
animation: spin 0.8s linear infinite;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
@keyframes spin {
|
|
75
|
+
from { transform: rotate(0deg); }
|
|
76
|
+
to { transform: rotate(360deg); }
|
|
77
|
+
}
|
|
78
|
+
|
|
16
79
|
.iframe {
|
|
17
80
|
border: none;
|
|
18
81
|
display: block;
|
|
82
|
+
position: relative;
|
|
83
|
+
z-index: 1;
|
|
19
84
|
}
|
|
20
85
|
|
|
21
|
-
.
|
|
86
|
+
.snapshotImage {
|
|
22
87
|
position: absolute;
|
|
23
88
|
inset: 0;
|
|
24
|
-
|
|
25
|
-
|
|
89
|
+
width: 100%;
|
|
90
|
+
height: 100%;
|
|
91
|
+
object-fit: cover;
|
|
92
|
+
object-position: top left;
|
|
93
|
+
display: block;
|
|
94
|
+
pointer-events: none;
|
|
95
|
+
transition: opacity 150ms ease;
|
|
26
96
|
}
|
|
27
97
|
|
|
28
98
|
.empty {
|
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
box-shadow: 2px 3px 10px rgba(0, 0, 0, 0.35);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/* Hide own border when parent widget slot is selected (avoid double focus ring) */
|
|
24
|
+
:global([data-widget-selected]) .sticky {
|
|
25
|
+
border-color: transparent;
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
.text {
|
|
24
29
|
padding: 16px 20px;
|
|
25
30
|
margin: 0;
|
|
@@ -13,16 +13,16 @@ describe('stickyNoteSchema', () => {
|
|
|
13
13
|
)
|
|
14
14
|
})
|
|
15
15
|
|
|
16
|
-
it('
|
|
16
|
+
it('includes default values for width/height from config', () => {
|
|
17
17
|
const defaults = getDefaults(stickyNoteSchema)
|
|
18
|
-
expect(defaults).
|
|
19
|
-
expect(defaults).
|
|
18
|
+
expect(defaults).toHaveProperty('width', 270)
|
|
19
|
+
expect(defaults).toHaveProperty('height', 170)
|
|
20
20
|
})
|
|
21
21
|
|
|
22
|
-
it('returns
|
|
22
|
+
it('returns default value when width/height are not saved in props', () => {
|
|
23
23
|
const props = { text: 'hello', color: 'yellow' }
|
|
24
|
-
expect(readProp(props, 'width', stickyNoteSchema)).
|
|
25
|
-
expect(readProp(props, 'height', stickyNoteSchema)).
|
|
24
|
+
expect(readProp(props, 'width', stickyNoteSchema)).toBe(270)
|
|
25
|
+
expect(readProp(props, 'height', stickyNoteSchema)).toBe(170)
|
|
26
26
|
})
|
|
27
27
|
|
|
28
28
|
it('returns saved width/height when present in props', () => {
|
|
@@ -33,11 +33,11 @@ describe('stickyNoteSchema', () => {
|
|
|
33
33
|
})
|
|
34
34
|
|
|
35
35
|
describe('StickyNote', () => {
|
|
36
|
-
it('
|
|
36
|
+
it('applies default dimensions as inline styles when not saved in props', () => {
|
|
37
37
|
const { container } = render(<StickyNote props={{ text: 'Hi' }} onUpdate={vi.fn()} />)
|
|
38
38
|
const sticky = container.querySelector('article')
|
|
39
|
-
expect(sticky.style.width).toBe('')
|
|
40
|
-
expect(sticky.style.height).toBe('')
|
|
39
|
+
expect(sticky.style.width).toBe('270px')
|
|
40
|
+
expect(sticky.style.height).toBe('170px')
|
|
41
41
|
})
|
|
42
42
|
|
|
43
43
|
it('applies saved dimensions as inline styles', () => {
|