@dfosco/storyboard-react 3.11.3 → 4.0.0-beta.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 +3 -3
- package/src/canvas/CanvasPage.jsx +1 -14
- package/src/canvas/widgets/PrototypeEmbed.jsx +2 -0
- 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.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"dependencies": {
|
|
6
|
-
"@dfosco/storyboard-core": "
|
|
7
|
-
"@dfosco/tiny-canvas": "
|
|
6
|
+
"@dfosco/storyboard-core": "4.0.0-beta.0",
|
|
7
|
+
"@dfosco/tiny-canvas": "4.0.0-beta.0",
|
|
8
8
|
"@neodrag/react": "^2.3.1",
|
|
9
9
|
"glob": "^11.0.0",
|
|
10
10
|
"jsonc-parser": "^3.3.1"
|
|
@@ -287,7 +287,6 @@ export default function CanvasPage({ name }) {
|
|
|
287
287
|
const zoomRef = useRef(initialViewport?.zoom ?? 100)
|
|
288
288
|
const scrollRef = useRef(null)
|
|
289
289
|
const pendingScrollRestore = useRef(initialViewport)
|
|
290
|
-
const initialWidgetParam = useRef(new URLSearchParams(window.location.search).has('widget'))
|
|
291
290
|
const [canvasTitle, setCanvasTitle] = useState(canvas?.title || name)
|
|
292
291
|
const titleInputRef = useRef(null)
|
|
293
292
|
const [localSources, setLocalSources] = useState(canvas?.sources ?? [])
|
|
@@ -396,8 +395,6 @@ export default function CanvasPage({ name }) {
|
|
|
396
395
|
setLocalWidgets(canvas?.widgets ?? null)
|
|
397
396
|
setLocalSources(canvas?.sources ?? [])
|
|
398
397
|
setCanvasTitle(canvas?.title || name)
|
|
399
|
-
setSnapEnabled(canvas?.snapToGrid ?? false)
|
|
400
|
-
setSnapGridSize(canvas?.gridSize || 40)
|
|
401
398
|
undoRedo.reset()
|
|
402
399
|
}
|
|
403
400
|
|
|
@@ -863,7 +860,7 @@ export default function CanvasPage({ name }) {
|
|
|
863
860
|
function handleSnapToggle() {
|
|
864
861
|
setSnapEnabled((prev) => {
|
|
865
862
|
const next = !prev
|
|
866
|
-
updateCanvas(name, {
|
|
863
|
+
updateCanvas(name, { snapToGrid: next }).catch((err) =>
|
|
867
864
|
console.error('[canvas] Failed to persist snap setting:', err)
|
|
868
865
|
)
|
|
869
866
|
return next
|
|
@@ -922,16 +919,6 @@ export default function CanvasPage({ name }) {
|
|
|
922
919
|
return () => document.removeEventListener('storyboard:canvas:zoom-to-fit', handleZoomToFit)
|
|
923
920
|
}, [localWidgets, localSources, jsxExports])
|
|
924
921
|
|
|
925
|
-
// On initial load without a ?widget= deep link, zoom to fit all objects.
|
|
926
|
-
// Wait for jsxExports when the canvas has a JSX module so components are
|
|
927
|
-
// included in the bounding-box calculation.
|
|
928
|
-
useEffect(() => {
|
|
929
|
-
if (loading || initialWidgetParam.current) return
|
|
930
|
-
if (canvas?._jsxModule && !jsxExports) return
|
|
931
|
-
initialWidgetParam.current = true // only once
|
|
932
|
-
document.dispatchEvent(new CustomEvent('storyboard:canvas:zoom-to-fit'))
|
|
933
|
-
}, [loading, jsxExports, canvas])
|
|
934
|
-
|
|
935
922
|
// Canvas background should follow toolbar theme target.
|
|
936
923
|
useEffect(() => {
|
|
937
924
|
function readMode() {
|
|
@@ -64,6 +64,8 @@ export default forwardRef(function PrototypeEmbed({ props, onUpdate, resizable }
|
|
|
64
64
|
|
|
65
65
|
const iframeSrc = useMemo(() => {
|
|
66
66
|
if (!rawSrc) return ''
|
|
67
|
+
// External URLs are embedded as-is — storyboard query params only apply to local prototypes
|
|
68
|
+
if (/^https?:\/\//.test(rawSrc)) return rawSrc
|
|
67
69
|
const hashIdx = rawSrc.indexOf('#')
|
|
68
70
|
const base = hashIdx >= 0 ? rawSrc.slice(0, hashIdx) : rawSrc
|
|
69
71
|
const hash = hashIdx >= 0 ? rawSrc.slice(hashIdx) : ''
|
|
@@ -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
|
|