@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 CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@dfosco/storyboard-react",
3
- "version": "3.12.0",
3
+ "version": "4.0.0-beta.1",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.12.0",
7
- "@dfosco/tiny-canvas": "3.12.0",
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
- try {
1103
- const parsed = new URL(text)
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 = parsed.pathname + parsed.search + parsed.hash
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
- } catch {
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((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