@dfosco/storyboard-react 4.0.0-beta.43 → 4.0.0-beta.45
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 +4 -3
- package/src/AuthModal/AuthModal.jsx +128 -0
- package/src/AuthModal/AuthModal.module.css +200 -0
- package/src/BranchBar/BranchBar.jsx +50 -0
- package/src/BranchBar/BranchBar.module.css +230 -0
- package/src/BranchBar/useBranches.js +79 -0
- package/src/CommandPalette/CommandPalette.jsx +923 -0
- package/src/CommandPalette/CreateDialog.jsx +219 -0
- package/src/CommandPalette/command-palette.css +66 -0
- package/src/Viewfinder.jsx +49 -11
- package/src/Viewfinder.module.css +43 -16
- package/src/canvas/CanvasPage.jsx +90 -4
- package/src/canvas/CanvasPage.module.css +25 -0
- package/src/canvas/MarqueeOverlay.jsx +20 -0
- package/src/canvas/PageSelector.jsx +20 -3
- package/src/canvas/componentIsolate.jsx +5 -5
- 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/ComponentWidget.jsx +1 -1
- package/src/canvas/widgets/PrototypeEmbed.jsx +1 -1
- package/src/canvas/widgets/StoryWidget.jsx +1 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +1 -1
- package/src/index.js +9 -0
- package/src/vite/data-plugin.js +2 -9
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CreateDialog — centered modal for creating storyboard artifacts.
|
|
3
|
+
* Triggered from the command palette's Create actions.
|
|
4
|
+
*/
|
|
5
|
+
import { useState, useEffect } from 'react'
|
|
6
|
+
|
|
7
|
+
const TYPE_LABELS = {
|
|
8
|
+
Canvas: 'Canvas',
|
|
9
|
+
Prototype: 'Prototype',
|
|
10
|
+
Component: 'Component',
|
|
11
|
+
Flow: 'Prototype Flow',
|
|
12
|
+
Page: 'Prototype Page',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default function CreateDialog({ type, basePath, onClose }) {
|
|
16
|
+
const [name, setName] = useState('')
|
|
17
|
+
const [title, setTitle] = useState('')
|
|
18
|
+
const [description, setDescription] = useState('')
|
|
19
|
+
const [url, setUrl] = useState('')
|
|
20
|
+
const [isExternal, setIsExternal] = useState(false)
|
|
21
|
+
const [prototype, setPrototype] = useState('')
|
|
22
|
+
const [prototypes, setPrototypes] = useState([])
|
|
23
|
+
const [error, setError] = useState('')
|
|
24
|
+
const [submitting, setSubmitting] = useState(false)
|
|
25
|
+
|
|
26
|
+
const needsPrototype = type === 'Flow' || type === 'Page'
|
|
27
|
+
|
|
28
|
+
// Reset form when type changes
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
setName('')
|
|
31
|
+
setTitle('')
|
|
32
|
+
setDescription('')
|
|
33
|
+
setUrl('')
|
|
34
|
+
setIsExternal(false)
|
|
35
|
+
setPrototype('')
|
|
36
|
+
setError('')
|
|
37
|
+
setSubmitting(false)
|
|
38
|
+
}, [type])
|
|
39
|
+
|
|
40
|
+
// Fetch prototypes list when needed
|
|
41
|
+
useEffect(() => {
|
|
42
|
+
if (!needsPrototype || !type) return
|
|
43
|
+
const apiBase = (basePath || '/').replace(/\/+$/, '')
|
|
44
|
+
fetch(`${apiBase}/_storyboard/workshop/flows`)
|
|
45
|
+
.then(r => r.ok ? r.json() : null)
|
|
46
|
+
.then(data => { if (data?.prototypes) setPrototypes(data.prototypes) })
|
|
47
|
+
.catch(() => {})
|
|
48
|
+
}, [needsPrototype, type, basePath])
|
|
49
|
+
|
|
50
|
+
if (!type) return null
|
|
51
|
+
|
|
52
|
+
function withBase(base, route) {
|
|
53
|
+
const b = (base || '/').replace(/\/+$/, '')
|
|
54
|
+
return b === '/' ? route : `${b}${route}`
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function handleSubmit(e) {
|
|
58
|
+
e.preventDefault()
|
|
59
|
+
if (!name.trim()) { setError('Name is required'); return }
|
|
60
|
+
if (needsPrototype && !prototype) { setError('Select a prototype'); return }
|
|
61
|
+
setError('')
|
|
62
|
+
setSubmitting(true)
|
|
63
|
+
|
|
64
|
+
const apiBase = (basePath || '/').replace(/\/+$/, '')
|
|
65
|
+
let endpoint, body
|
|
66
|
+
if (type === 'Canvas') {
|
|
67
|
+
endpoint = `${apiBase}/_storyboard/canvas/create`
|
|
68
|
+
body = { name: name.trim(), title: title.trim(), description: description.trim(), grid: true, gridSize: 24 }
|
|
69
|
+
} else if (type === 'Prototype') {
|
|
70
|
+
endpoint = `${apiBase}/_storyboard/workshop/prototypes`
|
|
71
|
+
body = { name: name.trim(), title: title.trim(), description: description.trim() }
|
|
72
|
+
if (isExternal) { body.external = true; body.url = url.trim() }
|
|
73
|
+
} else if (type === 'Flow') {
|
|
74
|
+
endpoint = `${apiBase}/_storyboard/workshop/flows`
|
|
75
|
+
body = { name: name.trim(), title: title.trim(), prototype, description: description.trim() }
|
|
76
|
+
} else if (type === 'Page') {
|
|
77
|
+
endpoint = `${apiBase}/_storyboard/workshop/pages`
|
|
78
|
+
body = { name: name.trim(), prototype }
|
|
79
|
+
} else {
|
|
80
|
+
endpoint = `${apiBase}/_storyboard/canvas/create-story`
|
|
81
|
+
body = { name: name.trim(), location: 'src/components' }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetch(endpoint, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: { 'Content-Type': 'application/json' },
|
|
88
|
+
body: JSON.stringify(body),
|
|
89
|
+
})
|
|
90
|
+
if (!res.ok) {
|
|
91
|
+
const text = await res.text()
|
|
92
|
+
throw new Error(text || `Request failed (${res.status})`)
|
|
93
|
+
}
|
|
94
|
+
const data = await res.json().catch(() => ({}))
|
|
95
|
+
const route = data.route || data.path || `/${name.trim()}`
|
|
96
|
+
window.location.href = withBase(basePath, route)
|
|
97
|
+
} catch (err) {
|
|
98
|
+
setError(err.message)
|
|
99
|
+
setSubmitting(false)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<div style={overlayStyle} onClick={onClose}>
|
|
105
|
+
<div style={dialogStyle} onClick={e => e.stopPropagation()}>
|
|
106
|
+
<div style={headerStyle}>
|
|
107
|
+
<span style={titleStyle}>New {TYPE_LABELS[type] || type}</span>
|
|
108
|
+
<button style={closeBtnStyle} onClick={onClose}>✕</button>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<form onSubmit={handleSubmit} style={formStyle}>
|
|
112
|
+
{needsPrototype && (
|
|
113
|
+
<label style={fieldStyle}>
|
|
114
|
+
<span style={labelStyle}>Prototype *</span>
|
|
115
|
+
<select style={inputStyle} value={prototype} onChange={e => setPrototype(e.target.value)}>
|
|
116
|
+
<option value="">Select a prototype…</option>
|
|
117
|
+
{prototypes.map(p => <option key={p.name} value={p.name}>{p.name}</option>)}
|
|
118
|
+
</select>
|
|
119
|
+
</label>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
<label style={fieldStyle}>
|
|
123
|
+
<span style={labelStyle}>Name *</span>
|
|
124
|
+
<input style={inputStyle} value={name} onChange={e => setName(e.target.value)}
|
|
125
|
+
placeholder={type === 'Page' ? 'my-page' : `my-${type.toLowerCase()}`} autoFocus />
|
|
126
|
+
</label>
|
|
127
|
+
|
|
128
|
+
{type !== 'Page' && (
|
|
129
|
+
<label style={fieldStyle}>
|
|
130
|
+
<span style={labelStyle}>Title</span>
|
|
131
|
+
<input style={inputStyle} value={title} onChange={e => setTitle(e.target.value)}
|
|
132
|
+
placeholder="Display title (optional)" />
|
|
133
|
+
</label>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{(type === 'Canvas' || type === 'Prototype') && (
|
|
137
|
+
<label style={fieldStyle}>
|
|
138
|
+
<span style={labelStyle}>Description</span>
|
|
139
|
+
<input style={inputStyle} value={description} onChange={e => setDescription(e.target.value)}
|
|
140
|
+
placeholder="Short description (optional)" />
|
|
141
|
+
</label>
|
|
142
|
+
)}
|
|
143
|
+
|
|
144
|
+
{type === 'Prototype' && (
|
|
145
|
+
<label style={checkboxFieldStyle}>
|
|
146
|
+
<input type="checkbox" checked={isExternal} onChange={e => setIsExternal(e.target.checked)} />
|
|
147
|
+
<span>External prototype</span>
|
|
148
|
+
</label>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{isExternal && (
|
|
152
|
+
<label style={fieldStyle}>
|
|
153
|
+
<span style={labelStyle}>URL *</span>
|
|
154
|
+
<input style={inputStyle} value={url} onChange={e => setUrl(e.target.value)}
|
|
155
|
+
placeholder="https://example.com" />
|
|
156
|
+
</label>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{error && <div style={errorStyle}>{error}</div>}
|
|
160
|
+
|
|
161
|
+
<button type="submit" disabled={submitting} style={submitStyle}>
|
|
162
|
+
{submitting ? 'Creating…' : `Create ${TYPE_LABELS[type] || type}`}
|
|
163
|
+
</button>
|
|
164
|
+
</form>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const overlayStyle = {
|
|
171
|
+
position: 'fixed', inset: 0, zIndex: 10001,
|
|
172
|
+
background: 'rgba(0,0,0,0.3)', backdropFilter: 'blur(2px)',
|
|
173
|
+
display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
|
|
174
|
+
paddingTop: '15vh',
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const dialogStyle = {
|
|
178
|
+
background: '#fff', borderRadius: 12, width: '100%', maxWidth: 420,
|
|
179
|
+
boxShadow: '0 16px 48px rgba(0,0,0,0.15)', overflow: 'hidden',
|
|
180
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const headerStyle = {
|
|
184
|
+
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
|
185
|
+
padding: '16px 20px 12px', borderBottom: '1px solid #e5e5e5',
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const titleStyle = { fontSize: 16, fontWeight: 600, color: '#1a1a1a' }
|
|
189
|
+
|
|
190
|
+
const closeBtnStyle = {
|
|
191
|
+
background: 'none', border: 'none', cursor: 'pointer',
|
|
192
|
+
fontSize: 16, color: '#999', padding: 4,
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const formStyle = { padding: '16px 20px 20px', display: 'flex', flexDirection: 'column', gap: 12 }
|
|
196
|
+
|
|
197
|
+
const fieldStyle = { display: 'flex', flexDirection: 'column', gap: 4 }
|
|
198
|
+
|
|
199
|
+
const labelStyle = { fontSize: 13, fontWeight: 500, color: '#555' }
|
|
200
|
+
|
|
201
|
+
const inputStyle = {
|
|
202
|
+
padding: '8px 10px', border: '1px solid #ddd', borderRadius: 6,
|
|
203
|
+
fontSize: 14, outline: 'none', fontFamily: 'inherit',
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const checkboxFieldStyle = {
|
|
207
|
+
display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: '#555',
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const errorStyle = {
|
|
211
|
+
fontSize: 13, color: '#ef4444', background: '#fef2f2',
|
|
212
|
+
padding: '6px 10px', borderRadius: 6,
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const submitStyle = {
|
|
216
|
+
padding: '10px 16px', background: '#1a1a1a', color: '#fff',
|
|
217
|
+
border: 'none', borderRadius: 8, fontSize: 14, fontWeight: 500,
|
|
218
|
+
cursor: 'pointer', fontFamily: 'inherit', marginTop: 4,
|
|
219
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Theme overrides for react-cmdk to match storyboard core-ui styling.
|
|
3
|
+
* The z-index is set above the CoreUIBar (9999) so the palette overlays everything.
|
|
4
|
+
*/
|
|
5
|
+
.command-palette {
|
|
6
|
+
z-index: 10001 !important;
|
|
7
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* Dark mode overrides for react-cmdk.
|
|
12
|
+
* react-cmdk uses @media (prefers-color-scheme: dark) internally,
|
|
13
|
+
* but we drive dark mode via data-color-mode on body. Override all
|
|
14
|
+
* colors with a single high-specificity block.
|
|
15
|
+
*/
|
|
16
|
+
body[data-color-mode="dark"] .command-palette {
|
|
17
|
+
color-scheme: dark;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
body[data-color-mode="dark"] .command-palette .command-palette-content {
|
|
21
|
+
color: #e6edf3 !important;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/* Panel background */
|
|
25
|
+
body[data-color-mode="dark"] .command-palette .bg-white {
|
|
26
|
+
background-color: #2d333b !important;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* Hover highlight */
|
|
30
|
+
body[data-color-mode="dark"] .command-palette .hover\:bg-gray-100:hover {
|
|
31
|
+
background-color: #373e47 !important;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Selected/active item highlight */
|
|
35
|
+
body[data-color-mode="dark"] .command-palette .bg-gray-200\/50 {
|
|
36
|
+
background-color: rgba(99, 110, 123, 0.4) !important;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/* Muted text (headings, badges, "Action" labels) */
|
|
40
|
+
body[data-color-mode="dark"] .command-palette .text-gray-400,
|
|
41
|
+
body[data-color-mode="dark"] .command-palette .text-gray-500 {
|
|
42
|
+
color: #768390 !important;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Input text */
|
|
46
|
+
body[data-color-mode="dark"] .command-palette input {
|
|
47
|
+
color: #e6edf3 !important;
|
|
48
|
+
}
|
|
49
|
+
body[data-color-mode="dark"] .command-palette .placeholder-gray-500::placeholder {
|
|
50
|
+
color: #636e7b !important;
|
|
51
|
+
}
|
|
52
|
+
body[data-color-mode="dark"] .command-palette .placeholder-gray-500::-moz-placeholder {
|
|
53
|
+
color: #636e7b !important;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Borders / dividers */
|
|
57
|
+
body[data-color-mode="dark"] .command-palette .divide-y > :not([hidden]) ~ :not([hidden]),
|
|
58
|
+
body[data-color-mode="dark"] .command-palette .border-t,
|
|
59
|
+
body[data-color-mode="dark"] .command-palette .border-b {
|
|
60
|
+
border-color: #444c56 !important;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/* Hide "Action" / "Link" type labels on all list items */
|
|
64
|
+
.command-palette .command-palette-list-item .text-gray-500.text-sm {
|
|
65
|
+
display: none !important;
|
|
66
|
+
}
|
package/src/Viewfinder.jsx
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { useState, useEffect, useMemo, useCallback, useSyncExternalStore } from 'react'
|
|
8
8
|
import { buildPrototypeIndex, listStories, getStoryData, getLocal, setLocal } from '@dfosco/storyboard-core'
|
|
9
|
-
import { MarkGithubIcon, GitBranchIcon, ChevronDownIcon, ChevronRightIcon, FileDirectoryFillIcon, PlusIcon, StarIcon, StarFillIcon, ThreeBarsIcon, XIcon } from '@primer/octicons-react'
|
|
9
|
+
import { MarkGithubIcon, GitBranchIcon, ChevronDownIcon, ChevronRightIcon, FileDirectoryFillIcon, PlusIcon, StarIcon, StarFillIcon, ThreeBarsIcon, XIcon, StackIcon } from '@primer/octicons-react'
|
|
10
10
|
import { Menu } from '@base-ui/react/menu'
|
|
11
11
|
import Icon from './Icon.jsx'
|
|
12
12
|
import css from './Viewfinder.module.css'
|
|
@@ -195,6 +195,7 @@ function ArtifactCard({ item, basePath, starred, onToggleStar }) {
|
|
|
195
195
|
<span className={css.cardBadge}>{getTypeLabel(item.type)}</span>
|
|
196
196
|
<div className={css.cardActions}>
|
|
197
197
|
{item.flows?.length > 0 && <FlowsDropdown flows={item.flows} basePath={basePath} />}
|
|
198
|
+
{item.pages?.length > 1 && <PagesDropdown pages={item.pages} basePath={basePath} />}
|
|
198
199
|
<StarBtn active={starred} onClick={() => onToggleStar(item.id)} />
|
|
199
200
|
</div>
|
|
200
201
|
</div>
|
|
@@ -276,6 +277,43 @@ function FlowsDropdown({ flows, basePath }) {
|
|
|
276
277
|
)
|
|
277
278
|
}
|
|
278
279
|
|
|
280
|
+
/* ─── Pages Dropdown ─── */
|
|
281
|
+
|
|
282
|
+
function PagesDropdown({ pages, basePath }) {
|
|
283
|
+
if (!pages || pages.length < 2) return null
|
|
284
|
+
return (
|
|
285
|
+
<Menu.Root>
|
|
286
|
+
<Menu.Trigger
|
|
287
|
+
className={css.iconBtn}
|
|
288
|
+
onClick={(e) => { e.preventDefault(); e.stopPropagation() }}
|
|
289
|
+
aria-label="See pages"
|
|
290
|
+
title="See pages"
|
|
291
|
+
>
|
|
292
|
+
<StackIcon size={16} />
|
|
293
|
+
</Menu.Trigger>
|
|
294
|
+
<Menu.Portal>
|
|
295
|
+
<Menu.Positioner className={css.flowsPositioner} side="bottom" align="end" sideOffset={4}>
|
|
296
|
+
<Menu.Popup className={css.flowsPopup}>
|
|
297
|
+
<div className={css.flowsTitle}>Pages</div>
|
|
298
|
+
{pages.map(page => (
|
|
299
|
+
<Menu.Item
|
|
300
|
+
key={page.route}
|
|
301
|
+
className={css.flowsItem}
|
|
302
|
+
onClick={(e) => {
|
|
303
|
+
e.preventDefault()
|
|
304
|
+
window.location.href = withBase(basePath, page.route)
|
|
305
|
+
}}
|
|
306
|
+
>
|
|
307
|
+
{page.name}
|
|
308
|
+
</Menu.Item>
|
|
309
|
+
))}
|
|
310
|
+
</Menu.Popup>
|
|
311
|
+
</Menu.Positioner>
|
|
312
|
+
</Menu.Portal>
|
|
313
|
+
</Menu.Root>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
279
317
|
/* ─── Folder Section ─── */
|
|
280
318
|
|
|
281
319
|
function FolderSection({ folder, collapsed, onToggle, basePath, starred, onToggleStar }) {
|
|
@@ -330,7 +368,7 @@ function CreateFooter() {
|
|
|
330
368
|
|
|
331
369
|
/* ─── Create Form ─── */
|
|
332
370
|
|
|
333
|
-
function CreateForm({ type,
|
|
371
|
+
function CreateForm({ type, onClose, basePath }) {
|
|
334
372
|
const [name, setName] = useState('')
|
|
335
373
|
const [title, setTitle] = useState('')
|
|
336
374
|
const [description, setDescription] = useState('')
|
|
@@ -404,10 +442,12 @@ function CreateForm({ type, onBack, onClose, basePath }) {
|
|
|
404
442
|
|
|
405
443
|
return (
|
|
406
444
|
<form onSubmit={handleSubmit}>
|
|
407
|
-
<
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
445
|
+
<div className={css.createFormHeader}>
|
|
446
|
+
<div className={css.createMenuTitle}>New {typeLabels[type] || type}</div>
|
|
447
|
+
<button type="button" className={css.createFormClose} onClick={onClose} aria-label="Close">
|
|
448
|
+
<XIcon size={16} />
|
|
449
|
+
</button>
|
|
450
|
+
</div>
|
|
411
451
|
|
|
412
452
|
{needsPrototype && (
|
|
413
453
|
<div className={css.createFormField}>
|
|
@@ -488,13 +528,10 @@ function CreateForm({ type, onBack, onClose, basePath }) {
|
|
|
488
528
|
{error && <div className={css.createFormError}>{error}</div>}
|
|
489
529
|
|
|
490
530
|
<div className={css.createFormActions}>
|
|
491
|
-
<button type="
|
|
492
|
-
|
|
493
|
-
{submitting ? 'Creating…' : 'Create'}
|
|
531
|
+
<button type="submit" className={css.createFormSubmit} disabled={submitting}>
|
|
532
|
+
{submitting ? 'Creating…' : `Create ${typeLabels[type] || type}`}
|
|
494
533
|
</button>
|
|
495
534
|
</div>
|
|
496
|
-
|
|
497
|
-
<CreateFooter />
|
|
498
535
|
</form>
|
|
499
536
|
)
|
|
500
537
|
}
|
|
@@ -785,6 +822,7 @@ export default function Viewfinder({
|
|
|
785
822
|
externalUrl: null,
|
|
786
823
|
folder: canvas.folder,
|
|
787
824
|
description: canvas.description,
|
|
825
|
+
pages: canvas.pages || null,
|
|
788
826
|
})
|
|
789
827
|
}
|
|
790
828
|
|
|
@@ -395,6 +395,7 @@
|
|
|
395
395
|
display: flex;
|
|
396
396
|
align-items: center;
|
|
397
397
|
gap: 0;
|
|
398
|
+
min-height: 52px;
|
|
398
399
|
padding: 0 32px;
|
|
399
400
|
background: var(--bgColor-default, #fff);
|
|
400
401
|
border-bottom: 1px solid var(--borderColor-default, #e5e5e5);
|
|
@@ -488,7 +489,7 @@
|
|
|
488
489
|
font-weight: 600;
|
|
489
490
|
text-transform: uppercase;
|
|
490
491
|
letter-spacing: 0.3px;
|
|
491
|
-
background:
|
|
492
|
+
background: var(--bgColor-neutral-muted, #f0f0f0);
|
|
492
493
|
color: var(--fgColor-muted, #555);
|
|
493
494
|
}
|
|
494
495
|
|
|
@@ -619,7 +620,6 @@
|
|
|
619
620
|
.createMenuTitle {
|
|
620
621
|
font-size: 18px;
|
|
621
622
|
font-weight: 600;
|
|
622
|
-
margin-bottom: 16px;
|
|
623
623
|
color: var(--fgColor-default, #1a1a1a);
|
|
624
624
|
}
|
|
625
625
|
|
|
@@ -673,6 +673,31 @@
|
|
|
673
673
|
|
|
674
674
|
/* Create form styles */
|
|
675
675
|
|
|
676
|
+
.createFormHeader {
|
|
677
|
+
display: flex;
|
|
678
|
+
align-items: center;
|
|
679
|
+
justify-content: space-between;
|
|
680
|
+
margin-bottom: 16px;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
.createFormClose {
|
|
684
|
+
display: flex;
|
|
685
|
+
align-items: center;
|
|
686
|
+
justify-content: center;
|
|
687
|
+
width: 28px;
|
|
688
|
+
height: 28px;
|
|
689
|
+
background: none;
|
|
690
|
+
border: none;
|
|
691
|
+
border-radius: 6px;
|
|
692
|
+
color: var(--fgColor-muted, #888);
|
|
693
|
+
cursor: pointer;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
.createFormClose:hover {
|
|
697
|
+
color: var(--fgColor-default, #1a1a1a);
|
|
698
|
+
background: var(--bgColor-neutral-muted, #f0f0f0);
|
|
699
|
+
}
|
|
700
|
+
|
|
676
701
|
.createFormField {
|
|
677
702
|
margin-bottom: 14px;
|
|
678
703
|
}
|
|
@@ -706,27 +731,29 @@
|
|
|
706
731
|
}
|
|
707
732
|
|
|
708
733
|
.createFormActions {
|
|
709
|
-
|
|
710
|
-
justify-content: flex-end;
|
|
711
|
-
gap: 8px;
|
|
712
|
-
margin-top: 16px;
|
|
734
|
+
margin-top: 20px;
|
|
713
735
|
}
|
|
714
736
|
|
|
715
|
-
.
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
737
|
+
.createFormSubmit {
|
|
738
|
+
width: 100%;
|
|
739
|
+
padding: 12px 16px;
|
|
740
|
+
background: var(--fgColor-default, #1a1a1a);
|
|
741
|
+
color: var(--bgColor-default, #fff);
|
|
720
742
|
border: none;
|
|
721
|
-
|
|
743
|
+
border-radius: 10px;
|
|
722
744
|
font-size: 16px;
|
|
745
|
+
font-weight: 600;
|
|
723
746
|
cursor: pointer;
|
|
724
|
-
|
|
725
|
-
margin-bottom: 12px;
|
|
747
|
+
transition: opacity 0.15s;
|
|
726
748
|
}
|
|
727
749
|
|
|
728
|
-
.
|
|
729
|
-
|
|
750
|
+
.createFormSubmit:hover {
|
|
751
|
+
opacity: 0.85;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
.createFormSubmit:disabled {
|
|
755
|
+
opacity: 0.4;
|
|
756
|
+
cursor: not-allowed;
|
|
730
757
|
}
|
|
731
758
|
|
|
732
759
|
.createFooter {
|
|
@@ -9,10 +9,13 @@ import { schemas, getDefaults } from './widgets/widgetProps.js'
|
|
|
9
9
|
import { getFeatures, isResizable } from './widgets/widgetConfig.js'
|
|
10
10
|
import { createPasteContext, resolvePaste } from './widgets/pasteRules.js'
|
|
11
11
|
import { getPasteRules } from '@dfosco/storyboard-core'
|
|
12
|
+
import { registerSmoothCorners } from '@dfosco/storyboard-core/smooth-corners'
|
|
12
13
|
import { isGitHubEmbedUrl } from './widgets/githubUrl.js'
|
|
13
14
|
import WidgetChrome from './widgets/WidgetChrome.jsx'
|
|
14
15
|
import ComponentWidget from './widgets/ComponentWidget.jsx'
|
|
15
16
|
import useUndoRedo from './useUndoRedo.js'
|
|
17
|
+
import useMarqueeSelect from './useMarqueeSelect.js'
|
|
18
|
+
import MarqueeOverlay from './MarqueeOverlay.jsx'
|
|
16
19
|
import {
|
|
17
20
|
addWidget as addWidgetApi,
|
|
18
21
|
checkGitHubCliAvailable,
|
|
@@ -23,6 +26,7 @@ import {
|
|
|
23
26
|
uploadImage,
|
|
24
27
|
} from './canvasApi.js'
|
|
25
28
|
import PageSelector from './PageSelector.jsx'
|
|
29
|
+
import Icon from '../Icon.jsx'
|
|
26
30
|
import { stories as storyIndex } from 'virtual:storyboard-data-index'
|
|
27
31
|
import styles from './CanvasPage.module.css'
|
|
28
32
|
|
|
@@ -35,6 +39,8 @@ const VIEWPORT_TTL_MS = 15 * 60 * 1000
|
|
|
35
39
|
const CANVAS_BRIDGE_STATE_KEY = '__storyboardCanvasBridgeState'
|
|
36
40
|
const GH_INSTALL_URL = 'https://github.com/cli/cli'
|
|
37
41
|
|
|
42
|
+
registerSmoothCorners()
|
|
43
|
+
|
|
38
44
|
/** Matches branch-deploy base path prefixes like /branch--my-feature/ */
|
|
39
45
|
const BRANCH_PREFIX_RE = /^\/branch--[^/]+/
|
|
40
46
|
|
|
@@ -1096,6 +1102,69 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1096
1102
|
}
|
|
1097
1103
|
}, [canvasId])
|
|
1098
1104
|
|
|
1105
|
+
// --- Selected widgets bridge ---
|
|
1106
|
+
// Writes .selectedwidgets.json so Copilot knows which canvas/widgets are active.
|
|
1107
|
+
// Uses a stable tabId to survive WebSocket reconnects.
|
|
1108
|
+
const selectionTabIdRef = useRef(Math.random().toString(36).slice(2, 10))
|
|
1109
|
+
|
|
1110
|
+
// Gather selected widget data from refs (safe for callbacks/timeouts)
|
|
1111
|
+
const getSelectedWidgetData = useCallback(() => {
|
|
1112
|
+
const ids = [...selectedIdsRef.current]
|
|
1113
|
+
const widgets = (stateRef.current.widgets || [])
|
|
1114
|
+
.filter(w => ids.includes(w.id))
|
|
1115
|
+
.map(w => ({ id: w.id, type: w.type, props: w.props }))
|
|
1116
|
+
|
|
1117
|
+
// Include jsx-* component selections
|
|
1118
|
+
for (const id of ids) {
|
|
1119
|
+
if (id.startsWith('jsx-') && !widgets.some(w => w.id === id)) {
|
|
1120
|
+
widgets.push({ id, type: 'component', props: { exportName: id.slice(4) } })
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
return { widgetIds: ids, widgets }
|
|
1125
|
+
}, [])
|
|
1126
|
+
|
|
1127
|
+
// Send focus event on mount, tab focus, and visibility change
|
|
1128
|
+
useEffect(() => {
|
|
1129
|
+
if (!import.meta.hot) return
|
|
1130
|
+
|
|
1131
|
+
const tabId = selectionTabIdRef.current
|
|
1132
|
+
|
|
1133
|
+
function sendFocus() {
|
|
1134
|
+
const { widgetIds, widgets } = getSelectedWidgetData()
|
|
1135
|
+
import.meta.hot.send('storyboard:canvas-focused', { tabId, canvasId, widgetIds, widgets })
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
sendFocus()
|
|
1139
|
+
|
|
1140
|
+
function handleVisibility() {
|
|
1141
|
+
if (!document.hidden) sendFocus()
|
|
1142
|
+
}
|
|
1143
|
+
function handleFocus() { sendFocus() }
|
|
1144
|
+
|
|
1145
|
+
document.addEventListener('visibilitychange', handleVisibility)
|
|
1146
|
+
window.addEventListener('focus', handleFocus)
|
|
1147
|
+
|
|
1148
|
+
return () => {
|
|
1149
|
+
document.removeEventListener('visibilitychange', handleVisibility)
|
|
1150
|
+
window.removeEventListener('focus', handleFocus)
|
|
1151
|
+
import.meta.hot.send('storyboard:canvas-unfocused', { tabId })
|
|
1152
|
+
}
|
|
1153
|
+
}, [canvasId, getSelectedWidgetData])
|
|
1154
|
+
|
|
1155
|
+
// Debounced selection change (500ms) — reads from refs at fire time
|
|
1156
|
+
useEffect(() => {
|
|
1157
|
+
if (!import.meta.hot) return
|
|
1158
|
+
|
|
1159
|
+
const tabId = selectionTabIdRef.current
|
|
1160
|
+
const timer = setTimeout(() => {
|
|
1161
|
+
const { widgetIds, widgets } = getSelectedWidgetData()
|
|
1162
|
+
import.meta.hot.send('storyboard:selection-changed', { tabId, canvasId, widgetIds: widgetIds, widgets })
|
|
1163
|
+
}, 500)
|
|
1164
|
+
|
|
1165
|
+
return () => clearTimeout(timer)
|
|
1166
|
+
}, [selectedWidgetIds, canvasId, getSelectedWidgetData])
|
|
1167
|
+
|
|
1099
1168
|
// Add a widget by type — used by CanvasControls and CoreUIBar event
|
|
1100
1169
|
const addWidget = useCallback(async (type) => {
|
|
1101
1170
|
const defaultProps = schemas[type] ? getDefaults(schemas[type]) : {}
|
|
@@ -1828,6 +1897,18 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1828
1897
|
// Stable callback for deselecting all widgets
|
|
1829
1898
|
const handleDeselectAll = useCallback(() => setSelectedWidgetIds(new Set()), [])
|
|
1830
1899
|
|
|
1900
|
+
// Marquee (lasso) multi-select on canvas background drag
|
|
1901
|
+
const { marqueeScreenRect, handleMarqueeMouseDown } = useMarqueeSelect({
|
|
1902
|
+
scrollRef,
|
|
1903
|
+
zoomRef: zoomRef,
|
|
1904
|
+
setSelectedWidgetIds,
|
|
1905
|
+
widgets: localWidgets,
|
|
1906
|
+
componentEntries,
|
|
1907
|
+
fallbackSizes: WIDGET_FALLBACK_SIZES,
|
|
1908
|
+
spaceHeld,
|
|
1909
|
+
isLocalDev,
|
|
1910
|
+
})
|
|
1911
|
+
|
|
1831
1912
|
// Stable callback for widget removal + deselect
|
|
1832
1913
|
const handleWidgetRemoveAndDeselect = useCallback((id) => {
|
|
1833
1914
|
handleWidgetRemove(id)
|
|
@@ -1865,7 +1946,7 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1865
1946
|
const canvasThemeVars = getCanvasThemeVars(canvasTheme)
|
|
1866
1947
|
const canvasPrimerAttrs = getCanvasPrimerAttrs(canvasTheme)
|
|
1867
1948
|
|
|
1868
|
-
// Merge JSX-sourced widgets
|
|
1949
|
+
// Merge JSX-sourced widgets and JSON widgets
|
|
1869
1950
|
const allChildren = []
|
|
1870
1951
|
|
|
1871
1952
|
// 1. Component widgets (from jsxExports or sources fallback)
|
|
@@ -1954,7 +2035,12 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1954
2035
|
return (
|
|
1955
2036
|
<>
|
|
1956
2037
|
<div className={styles.canvasTitle}>
|
|
1957
|
-
<
|
|
2038
|
+
<a href={(import.meta.env?.BASE_URL || '/')} className={`${styles.canvasLogo} smooth-corners`} aria-label="Go to homepage">
|
|
2039
|
+
<Icon name="iconoir/key-command" size={16} color="#fff" />
|
|
2040
|
+
</a>
|
|
2041
|
+
{siblingPages.length > 1 && (
|
|
2042
|
+
<h1 className={styles.canvasTitleStatic}>{canvasMeta?.title || canvas?.title || canvasId.split('/').pop()}</h1>
|
|
2043
|
+
)}
|
|
1958
2044
|
<PageSelector currentName={canvasId} pages={siblingPages} isLocalDev={isLocalDev} />
|
|
1959
2045
|
{isLocalDev && (
|
|
1960
2046
|
<span className={styles.localEditingLabel}>Local editing</span>
|
|
@@ -1970,9 +2056,9 @@ export default function CanvasPage({ canvasId: canvasIdProp, name, siblingPages
|
|
|
1970
2056
|
...canvasThemeVars,
|
|
1971
2057
|
...(spaceHeld ? { cursor: panningActive ? 'grabbing' : 'grab' } : {}),
|
|
1972
2058
|
}}
|
|
1973
|
-
|
|
1974
|
-
onMouseDown={handlePanStart}
|
|
2059
|
+
onMouseDown={(e) => { handlePanStart(e); handleMarqueeMouseDown(e); }}
|
|
1975
2060
|
>
|
|
2061
|
+
<MarqueeOverlay rect={marqueeScreenRect} />
|
|
1976
2062
|
<div
|
|
1977
2063
|
ref={zoomElRef}
|
|
1978
2064
|
data-storyboard-canvas-zoom
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
.canvasScroll {
|
|
18
|
+
position: relative;
|
|
18
19
|
width: 100vw;
|
|
19
20
|
height: calc(100vh - var(--sb-branch-bar-height, 0px));
|
|
20
21
|
overflow: auto;
|
|
@@ -41,6 +42,20 @@
|
|
|
41
42
|
|
|
42
43
|
/* Selection outline is now handled by WidgetChrome.module.css (.widgetSlotSelected) */
|
|
43
44
|
|
|
45
|
+
.canvasLogo {
|
|
46
|
+
width: 32px;
|
|
47
|
+
height: 32px;
|
|
48
|
+
background: var(--bgColor-emphasis, #313131);
|
|
49
|
+
border-radius: 6px;
|
|
50
|
+
display: flex;
|
|
51
|
+
align-items: center;
|
|
52
|
+
justify-content: center;
|
|
53
|
+
color: var(--fgColor-onEmphasis, #fff);
|
|
54
|
+
flex-shrink: 0;
|
|
55
|
+
text-decoration: none;
|
|
56
|
+
transform: rotate(-1deg);
|
|
57
|
+
}
|
|
58
|
+
|
|
44
59
|
.canvasTitle {
|
|
45
60
|
position: fixed;
|
|
46
61
|
top: calc(12px + var(--sb-branch-bar-height, 0px));
|
|
@@ -140,3 +155,13 @@
|
|
|
140
155
|
.ghInstallBannerDismiss:hover {
|
|
141
156
|
background: var(--bgColor-muted, #f6f8fa);
|
|
142
157
|
}
|
|
158
|
+
|
|
159
|
+
/* Marquee selection rectangle */
|
|
160
|
+
.marqueeRect {
|
|
161
|
+
position: absolute;
|
|
162
|
+
background: rgba(56, 132, 255, 0.12);
|
|
163
|
+
border: 1.5px solid rgba(56, 132, 255, 0.6);
|
|
164
|
+
border-radius: 2px;
|
|
165
|
+
pointer-events: none;
|
|
166
|
+
z-index: 9999;
|
|
167
|
+
}
|