@dfosco/storyboard-react 3.12.0 → 4.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/canvas/CanvasPage.jsx +15 -4
- package/src/canvas/widgets/WidgetChrome.jsx +55 -7
- package/src/canvas/widgets/WidgetChrome.module.css +26 -0
- package/src/canvas/widgets/widgetConfig.js +4 -0
- package/src/vite/data-plugin.js +0 -2
- package/src/vite/data-plugin.test.js +2 -2
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0-beta.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "
|
|
7
|
-
"@dfosco/tiny-canvas": "
|
|
6
|
+
"@dfosco/storyboard-core": "4.0.0-beta.1",
|
|
7
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.1",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1"
|
|
@@ -1017,6 +1017,17 @@ export default function CanvasPage({ name }) {
|
|
|
1017
1017
|
return pathname
|
|
1018
1018
|
}
|
|
1019
1019
|
|
|
1020
|
+
/** Parse text as a web URL (http/https only). Returns URL object or null. */
|
|
1021
|
+
function looksLikeWebUrl(text) {
|
|
1022
|
+
try {
|
|
1023
|
+
const url = new URL(text)
|
|
1024
|
+
if (url.protocol === 'http:' || url.protocol === 'https:') return url
|
|
1025
|
+
return null
|
|
1026
|
+
} catch {
|
|
1027
|
+
return null
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1020
1031
|
function blobToDataUrl(blob) {
|
|
1021
1032
|
return new Promise((resolve, reject) => {
|
|
1022
1033
|
const reader = new FileReader()
|
|
@@ -1099,13 +1110,13 @@ export default function CanvasPage({ name }) {
|
|
|
1099
1110
|
e.preventDefault()
|
|
1100
1111
|
|
|
1101
1112
|
let type, props
|
|
1102
|
-
|
|
1103
|
-
|
|
1113
|
+
const url = looksLikeWebUrl(text)
|
|
1114
|
+
if (url) {
|
|
1104
1115
|
if (isFigmaUrl(text)) {
|
|
1105
1116
|
type = 'figma-embed'
|
|
1106
1117
|
props = { url: sanitizeFigmaUrl(text), width: 800, height: 450 }
|
|
1107
1118
|
} else if (isSameOriginPrototype(text)) {
|
|
1108
|
-
const pathPortion =
|
|
1119
|
+
const pathPortion = url.pathname + url.search + url.hash
|
|
1109
1120
|
const src = extractPrototypeSrc(pathPortion)
|
|
1110
1121
|
type = 'prototype'
|
|
1111
1122
|
props = { src: src || '/', originalSrc: src || '/', label: '', width: 800, height: 600 }
|
|
@@ -1113,7 +1124,7 @@ export default function CanvasPage({ name }) {
|
|
|
1113
1124
|
type = 'link-preview'
|
|
1114
1125
|
props = { url: text, title: '' }
|
|
1115
1126
|
}
|
|
1116
|
-
}
|
|
1127
|
+
} else {
|
|
1117
1128
|
type = 'markdown'
|
|
1118
1129
|
props = { content: text }
|
|
1119
1130
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useCallback, useRef, useEffect } from 'react'
|
|
1
|
+
import { useState, useCallback, useRef, useEffect, useSyncExternalStore } from 'react'
|
|
2
2
|
import { Tooltip } from '@primer/react'
|
|
3
3
|
import { EyeIcon as OcticonEye, EyeClosedIcon as OcticonEyeClosed } from '@primer/octicons-react'
|
|
4
4
|
import styles from './WidgetChrome.module.css'
|
|
@@ -130,12 +130,45 @@ const ICON_REGISTRY = {
|
|
|
130
130
|
/** Danger-styled actions in the overflow menu. */
|
|
131
131
|
const DANGER_ACTIONS = new Set(['delete'])
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* useAltKey — tracks whether the Alt/Option key is currently held.
|
|
135
|
+
* Uses useSyncExternalStore for tear-free reads across concurrent renders.
|
|
136
|
+
*/
|
|
137
|
+
let altKeyHeld = false
|
|
138
|
+
const altKeyListeners = new Set()
|
|
139
|
+
function notifyAltKeyListeners() {
|
|
140
|
+
for (const cb of altKeyListeners) cb()
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (typeof window !== 'undefined') {
|
|
144
|
+
window.addEventListener('keydown', (e) => {
|
|
145
|
+
if (e.key === 'Alt' && !altKeyHeld) { altKeyHeld = true; notifyAltKeyListeners() }
|
|
146
|
+
})
|
|
147
|
+
window.addEventListener('keyup', (e) => {
|
|
148
|
+
if (e.key === 'Alt' && altKeyHeld) { altKeyHeld = false; notifyAltKeyListeners() }
|
|
149
|
+
})
|
|
150
|
+
window.addEventListener('blur', () => {
|
|
151
|
+
if (altKeyHeld) { altKeyHeld = false; notifyAltKeyListeners() }
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function subscribeAltKey(cb) {
|
|
156
|
+
altKeyListeners.add(cb)
|
|
157
|
+
return () => altKeyListeners.delete(cb)
|
|
158
|
+
}
|
|
159
|
+
function getAltKeySnapshot() { return altKeyHeld }
|
|
160
|
+
|
|
161
|
+
function useAltKey() {
|
|
162
|
+
return useSyncExternalStore(subscribeAltKey, getAltKeySnapshot, () => false)
|
|
163
|
+
}
|
|
164
|
+
|
|
133
165
|
/**
|
|
134
166
|
* Overflow menu — `...` button that opens a dropdown with menu-only actions.
|
|
135
167
|
*/
|
|
136
168
|
function WidgetOverflowMenu({ widgetId, menuFeatures, onAction }) {
|
|
137
169
|
const [open, setOpen] = useState(false)
|
|
138
170
|
const menuRef = useRef(null)
|
|
171
|
+
const altHeld = useAltKey()
|
|
139
172
|
|
|
140
173
|
useEffect(() => {
|
|
141
174
|
if (!open) return
|
|
@@ -148,17 +181,21 @@ function WidgetOverflowMenu({ widgetId, menuFeatures, onAction }) {
|
|
|
148
181
|
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
149
182
|
}, [open])
|
|
150
183
|
|
|
151
|
-
const handleItemClick = useCallback((
|
|
184
|
+
const handleItemClick = useCallback((feature, e) => {
|
|
152
185
|
e.stopPropagation()
|
|
186
|
+
const action = (altHeld && feature.alt) ? feature.alt.action : feature.action
|
|
153
187
|
if (action === 'copy-link') {
|
|
154
188
|
const url = new URL(window.location.href)
|
|
155
189
|
url.searchParams.set('widget', widgetId)
|
|
156
190
|
navigator.clipboard.writeText(url.toString()).catch(() => {})
|
|
191
|
+
} else if (action === 'copy-widget-id') {
|
|
192
|
+
const canvasName = window.location.pathname.split('/').filter(Boolean).pop() || ''
|
|
193
|
+
navigator.clipboard.writeText(`${canvasName}/${widgetId}`).catch(() => {})
|
|
157
194
|
} else {
|
|
158
195
|
onAction?.(action)
|
|
159
196
|
}
|
|
160
197
|
setOpen(false)
|
|
161
|
-
}, [widgetId, onAction])
|
|
198
|
+
}, [widgetId, onAction, altHeld])
|
|
162
199
|
|
|
163
200
|
return (
|
|
164
201
|
<div ref={menuRef} className={styles.overflowWrapper}>
|
|
@@ -176,16 +213,20 @@ function WidgetOverflowMenu({ widgetId, menuFeatures, onAction }) {
|
|
|
176
213
|
<div className={styles.overflowMenu}>
|
|
177
214
|
{menuFeatures.map((feature) => {
|
|
178
215
|
const Icon = ICON_REGISTRY[feature.icon]
|
|
179
|
-
const
|
|
216
|
+
const hasAlt = !!feature.alt
|
|
217
|
+
const label = (altHeld && hasAlt) ? feature.alt.label : (feature.label || feature.action)
|
|
180
218
|
const isDanger = DANGER_ACTIONS.has(feature.action)
|
|
181
219
|
return (
|
|
182
220
|
<button
|
|
183
221
|
key={feature.id}
|
|
184
222
|
className={`${styles.overflowItem} ${isDanger ? styles.overflowItemDanger : ''}`}
|
|
185
|
-
onClick={(e) => handleItemClick(feature
|
|
223
|
+
onClick={(e) => handleItemClick(feature, e)}
|
|
186
224
|
>
|
|
187
225
|
{Icon && <Icon />}
|
|
188
226
|
<span>{label}</span>
|
|
227
|
+
{hasAlt && (
|
|
228
|
+
<span className={`${styles.altHint} ${altHeld ? styles.altHintActive : ''}`}>⌥ alt</span>
|
|
229
|
+
)}
|
|
189
230
|
</button>
|
|
190
231
|
)
|
|
191
232
|
})}
|
|
@@ -202,6 +243,7 @@ function WidgetOverflowMenu({ widgetId, menuFeatures, onAction }) {
|
|
|
202
243
|
function DropdownFeature({ feature, onAction }) {
|
|
203
244
|
const [open, setOpen] = useState(false)
|
|
204
245
|
const menuRef = useRef(null)
|
|
246
|
+
const altHeld = useAltKey()
|
|
205
247
|
|
|
206
248
|
useEffect(() => {
|
|
207
249
|
if (!open) return
|
|
@@ -232,18 +274,24 @@ function DropdownFeature({ feature, onAction }) {
|
|
|
232
274
|
<div className={styles.overflowMenu}>
|
|
233
275
|
{(feature.items || []).map((item) => {
|
|
234
276
|
const Icon = ICON_REGISTRY[item.icon]
|
|
277
|
+
const hasAlt = !!item.alt
|
|
278
|
+
const label = (altHeld && hasAlt) ? item.alt.label : (item.label || item.action)
|
|
279
|
+
const action = (altHeld && hasAlt) ? item.alt.action : item.action
|
|
235
280
|
return (
|
|
236
281
|
<button
|
|
237
282
|
key={item.action}
|
|
238
283
|
className={styles.overflowItem}
|
|
239
284
|
onClick={(e) => {
|
|
240
285
|
e.stopPropagation()
|
|
241
|
-
onAction?.(
|
|
286
|
+
onAction?.(action)
|
|
242
287
|
setOpen(false)
|
|
243
288
|
}}
|
|
244
289
|
>
|
|
245
290
|
{Icon && <Icon />}
|
|
246
|
-
<span>{
|
|
291
|
+
<span>{label}</span>
|
|
292
|
+
{hasAlt && (
|
|
293
|
+
<span className={`${styles.altHint} ${altHeld ? styles.altHintActive : ''}`}>⌥ alt</span>
|
|
294
|
+
)}
|
|
247
295
|
</button>
|
|
248
296
|
)
|
|
249
297
|
})}
|
|
@@ -288,3 +288,29 @@
|
|
|
288
288
|
background: var(--bgColor-danger-muted, #490202);
|
|
289
289
|
color: var(--fgColor-danger, #f85149);
|
|
290
290
|
}
|
|
291
|
+
|
|
292
|
+
/* Alt hint — muted label on the right side of a menu item */
|
|
293
|
+
.altHint {
|
|
294
|
+
margin-left: auto;
|
|
295
|
+
font-size: 11px;
|
|
296
|
+
color: var(--fgColor-muted, #656d76);
|
|
297
|
+
padding: 1px 6px;
|
|
298
|
+
border-radius: 4px;
|
|
299
|
+
transition: background 80ms, color 80ms;
|
|
300
|
+
white-space: nowrap;
|
|
301
|
+
pointer-events: none;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
:global([data-sb-canvas-theme^='dark']) .altHint {
|
|
305
|
+
color: var(--fgColor-muted, #8b949e);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.altHintActive {
|
|
309
|
+
background: var(--bgColor-neutral-muted, #eaeef2);
|
|
310
|
+
color: var(--fgColor-default, #1f2328);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
:global([data-sb-canvas-theme^='dark']) .altHintActive {
|
|
314
|
+
background: var(--bgColor-neutral-muted, #272c33);
|
|
315
|
+
color: var(--fgColor-default, #e6edf3);
|
|
316
|
+
}
|
|
@@ -37,6 +37,10 @@ function resolveFeature(feature) {
|
|
|
37
37
|
for (const [k, v] of Object.entries(item)) r[k] = resolveVar(v)
|
|
38
38
|
return r
|
|
39
39
|
})
|
|
40
|
+
} else if (key === 'alt' && val && typeof val === 'object') {
|
|
41
|
+
const r = {}
|
|
42
|
+
for (const [k, v] of Object.entries(val)) r[k] = resolveVar(v)
|
|
43
|
+
resolved[key] = r
|
|
40
44
|
} else {
|
|
41
45
|
resolved[key] = resolveVar(val)
|
|
42
46
|
}
|
package/src/vite/data-plugin.js
CHANGED
|
@@ -509,8 +509,6 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes }, root)
|
|
|
509
509
|
}
|
|
510
510
|
for (const [route, flows] of Object.entries(routeGroups)) {
|
|
511
511
|
if (flows.length > 1) {
|
|
512
|
-
const labels = flows.map(f => ` - ${f.name}${f.isDefault ? ' (default)' : ''}`).join('\n')
|
|
513
|
-
console.log(`[storyboard-data] Route "${route}" has ${flows.length} flows:\n${labels}`)
|
|
514
512
|
const defaults = flows.filter(f => f.isDefault)
|
|
515
513
|
if (defaults.length > 1) {
|
|
516
514
|
console.warn(
|
|
@@ -358,7 +358,7 @@ describe('flow route inference', () => {
|
|
|
358
358
|
expect(code).not.toContain('"_route"')
|
|
359
359
|
})
|
|
360
360
|
|
|
361
|
-
it('
|
|
361
|
+
it('does not log info when multiple flows share the same route', () => {
|
|
362
362
|
mkdirSync(path.join(tmpDir, 'src', 'prototypes', 'Dashboard'), { recursive: true })
|
|
363
363
|
writeFileSync(
|
|
364
364
|
path.join(tmpDir, 'src', 'prototypes', 'Dashboard', 'happy.flow.json'),
|
|
@@ -376,7 +376,7 @@ describe('flow route inference', () => {
|
|
|
376
376
|
const routeLog = logSpy.mock.calls.find(call =>
|
|
377
377
|
typeof call[0] === 'string' && call[0].includes('Route "/Dashboard" has 2 flows')
|
|
378
378
|
)
|
|
379
|
-
expect(routeLog).
|
|
379
|
+
expect(routeLog).toBeUndefined()
|
|
380
380
|
logSpy.mockRestore()
|
|
381
381
|
})
|
|
382
382
|
|