@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 CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "3.11.3",
3
+ "version": "4.0.0-beta.0",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.11.3",
7
- "@dfosco/tiny-canvas": "3.11.3",
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, { settings: { snapToGrid: next } }).catch((err) =>
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((action, e) => {
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 label = feature.label || feature.action
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.action, e)}
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?.(item.action)
286
+ onAction?.(action)
242
287
  setOpen(false)
243
288
  }}
244
289
  >
245
290
  {Icon && <Icon />}
246
- <span>{item.label || item.action}</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
  }
@@ -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('logs info when multiple flows share the same route', () => {
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).toBeTruthy()
379
+ expect(routeLog).toBeUndefined()
380
380
  logSpy.mockRestore()
381
381
  })
382
382