@dfosco/storyboard-react 3.11.0-beta.2 → 3.11.0-beta.3

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.0-beta.2",
3
+ "version": "3.11.0-beta.3",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.11.0-beta.2",
7
- "@dfosco/tiny-canvas": "3.11.0-beta.2",
6
+ "@dfosco/storyboard-core": "3.11.0-beta.3",
7
+ "@dfosco/tiny-canvas": "3.11.0-beta.3",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1"
@@ -446,29 +446,49 @@ export default function CanvasPage({ name }) {
446
446
  const targetId = params.get('widget')
447
447
  if (!targetId || loading) return
448
448
 
449
- const widgets = localWidgets ?? []
450
- const widget = widgets.find((w) => w.id === targetId)
451
- if (!widget) return
452
-
453
449
  const el = scrollRef.current
454
450
  if (!el) return
455
451
 
456
- const scale = zoomRef.current / 100
457
- const fallback = WIDGET_FALLBACK_SIZES[widget.type] || { width: 200, height: 150 }
458
- const wW = (widget.props?.width ?? fallback.width) * scale
459
- const wH = (widget.props?.height ?? fallback.height) * scale
460
- const wX = (widget.position?.x ?? 0) * scale
461
- const wY = (widget.position?.y ?? 0) * scale
452
+ let x, y, w, h
453
+
454
+ // Check JSON widgets first
455
+ const widgets = localWidgets ?? []
456
+ const widget = widgets.find((wgt) => wgt.id === targetId)
457
+ if (widget) {
458
+ const fallback = WIDGET_FALLBACK_SIZES[widget.type] || { width: 200, height: 150 }
459
+ x = widget.position?.x ?? 0
460
+ y = widget.position?.y ?? 0
461
+ w = widget.props?.width ?? fallback.width
462
+ h = widget.props?.height ?? fallback.height
463
+ }
462
464
 
463
- // Center the widget in the viewport
464
- el.scrollLeft = wX + wW / 2 - el.clientWidth / 2
465
- el.scrollTop = wY + wH / 2 - el.clientHeight / 2
465
+ // Check JSX sources (jsx-ExportName)
466
+ if (!widget && targetId.startsWith('jsx-')) {
467
+ const exportName = targetId.slice(4)
468
+ const sourceMap = Object.fromEntries(
469
+ (localSources || []).filter((s) => s?.export).map((s) => [s.export, s])
470
+ )
471
+ const sourceData = sourceMap[exportName]
472
+ if (sourceData || (jsxExports && exportName in jsxExports)) {
473
+ const fallback = WIDGET_FALLBACK_SIZES['component']
474
+ x = sourceData?.position?.x ?? 0
475
+ y = sourceData?.position?.y ?? 0
476
+ w = sourceData?.width ?? fallback.width
477
+ h = sourceData?.height ?? fallback.height
478
+ }
479
+ }
480
+
481
+ if (x == null) return
482
+
483
+ const scale = zoomRef.current / 100
484
+ el.scrollLeft = (x + w / 2) * scale - el.clientWidth / 2
485
+ el.scrollTop = (y + h / 2) * scale - el.clientHeight / 2
466
486
 
467
487
  // Clean the URL param without triggering navigation
468
488
  const url = new URL(window.location.href)
469
489
  url.searchParams.delete('widget')
470
490
  window.history.replaceState({}, '', url.toString())
471
- }, [loading, localWidgets])
491
+ }, [loading, localWidgets, localSources, jsxExports])
472
492
 
473
493
  // Persist viewport state (zoom + scroll) to localStorage on changes
474
494
  useEffect(() => {
@@ -1065,6 +1085,7 @@ export default function CanvasPage({ name }) {
1065
1085
  }}
1066
1086
  >
1067
1087
  <WidgetChrome
1088
+ widgetId={`jsx-${exportName}`}
1068
1089
  features={componentFeatures}
1069
1090
  selected={selectedWidgetId === `jsx-${exportName}`}
1070
1091
  onSelect={() => setSelectedWidgetId(`jsx-${exportName}`)}
@@ -51,6 +51,28 @@ const ImageWidget = forwardRef(function ImageWidget({ props, onUpdate }, ref) {
51
51
  }).catch((err) => {
52
52
  console.error('[canvas] Failed to toggle image privacy:', err)
53
53
  })
54
+ } else if (actionId === 'download-image') {
55
+ if (!src) return
56
+ const url = getImageUrl(src)
57
+ const a = document.createElement('a')
58
+ a.href = url
59
+ a.download = src.replace(/^_/, '')
60
+ document.body.appendChild(a)
61
+ a.click()
62
+ document.body.removeChild(a)
63
+ } else if (actionId === 'copy-as-png') {
64
+ if (!src) return
65
+ const url = getImageUrl(src)
66
+ fetch(url)
67
+ .then((r) => r.blob())
68
+ .then((blob) => {
69
+ const pngBlob = blob.type === 'image/png' ? blob : blob
70
+ navigator.clipboard.write([new ClipboardItem({ 'image/png': pngBlob })]).catch(() => {})
71
+ })
72
+ .catch((err) => console.error('[canvas] Failed to copy image:', err))
73
+ } else if (actionId === 'copy-file-path') {
74
+ if (!src) return
75
+ navigator.clipboard.writeText(`src/canvas/images/${src}`).catch(() => {})
54
76
  }
55
77
  }
56
78
  }), [src, onUpdate])
@@ -85,6 +85,23 @@ function LinkIcon() {
85
85
  )
86
86
  }
87
87
 
88
+ function ChevronDownIcon() {
89
+ return (
90
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
91
+ <path d="M12.78 5.22a.749.749 0 0 1 0 1.06l-4.25 4.25a.749.749 0 0 1-1.06 0L3.22 6.28a.749.749 0 1 1 1.06-1.06L8 8.939l3.72-3.719a.749.749 0 0 1 1.06 0Z" />
92
+ </svg>
93
+ )
94
+ }
95
+
96
+ function DownloadIcon() {
97
+ return (
98
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
99
+ <path d="M2.75 14A1.75 1.75 0 0 1 1 12.25v-2.5a.75.75 0 0 1 1.5 0v2.5c0 .138.112.25.25.25h10.5a.25.25 0 0 0 .25-.25v-2.5a.75.75 0 0 1 1.5 0v2.5A1.75 1.75 0 0 1 13.25 14Z" />
100
+ <path d="M7.25 7.689V2a.75.75 0 0 1 1.5 0v5.689l1.97-1.969a.749.749 0 1 1 1.06 1.06l-3.25 3.25a.749.749 0 0 1-1.06 0L4.22 6.78a.749.749 0 1 1 1.06-1.06Z" />
101
+ </svg>
102
+ )
103
+ }
104
+
88
105
  /** Icon registry — maps icon name strings from config to React components. */
89
106
  const ICON_REGISTRY = {
90
107
  'trash': DeleteIcon,
@@ -97,6 +114,8 @@ const ICON_REGISTRY = {
97
114
  'copy': CopyIcon,
98
115
  'link': LinkIcon,
99
116
  'more': MoreIcon,
117
+ 'chevron-down': ChevronDownIcon,
118
+ 'download': DownloadIcon,
100
119
  }
101
120
 
102
121
  /** Danger-styled actions in the overflow menu. */
@@ -167,6 +186,64 @@ function WidgetOverflowMenu({ widgetId, menuFeatures, onAction }) {
167
186
  )
168
187
  }
169
188
 
189
+ /**
190
+ * Dropdown feature — a chevron button that opens a menu of actions.
191
+ * Items and their icons/labels come from config.
192
+ */
193
+ function DropdownFeature({ feature, onAction }) {
194
+ const [open, setOpen] = useState(false)
195
+ const menuRef = useRef(null)
196
+
197
+ useEffect(() => {
198
+ if (!open) return
199
+ function handlePointerDown(e) {
200
+ if (menuRef.current && !menuRef.current.contains(e.target)) {
201
+ setOpen(false)
202
+ }
203
+ }
204
+ document.addEventListener('pointerdown', handlePointerDown)
205
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
206
+ }, [open])
207
+
208
+ const TriggerIcon = ICON_REGISTRY[feature.icon] || ChevronDownIcon
209
+
210
+ return (
211
+ <div ref={menuRef} className={styles.overflowWrapper}>
212
+ <Tooltip text={feature.label || 'Actions'} direction="n">
213
+ <button
214
+ className={styles.featureBtn}
215
+ onClick={(e) => { e.stopPropagation(); setOpen((v) => !v) }}
216
+ aria-label={feature.label || 'Actions'}
217
+ aria-expanded={open}
218
+ >
219
+ <TriggerIcon />
220
+ </button>
221
+ </Tooltip>
222
+ {open && (
223
+ <div className={styles.overflowMenu}>
224
+ {(feature.items || []).map((item) => {
225
+ const Icon = ICON_REGISTRY[item.icon]
226
+ return (
227
+ <button
228
+ key={item.action}
229
+ className={styles.overflowItem}
230
+ onClick={(e) => {
231
+ e.stopPropagation()
232
+ onAction?.(item.action)
233
+ setOpen(false)
234
+ }}
235
+ >
236
+ {Icon && <Icon />}
237
+ <span>{item.label || item.action}</span>
238
+ </button>
239
+ )
240
+ })}
241
+ </div>
242
+ )}
243
+ </div>
244
+ )
245
+ }
246
+
170
247
  /**
171
248
  * ColorPicker feature button — shows a dot that reveals color options on hover.
172
249
  */
@@ -347,6 +424,22 @@ export default function WidgetChrome({
347
424
  )
348
425
  }
349
426
 
427
+ if (feature.type === 'dropdown') {
428
+ return (
429
+ <DropdownFeature
430
+ key={feature.id}
431
+ feature={feature}
432
+ onAction={(actionId) => {
433
+ if (widgetRef?.current?.handleAction) {
434
+ widgetRef.current.handleAction(actionId)
435
+ } else {
436
+ onAction?.(actionId)
437
+ }
438
+ }}
439
+ />
440
+ )
441
+ }
442
+
350
443
  return null
351
444
  })}
352
445
  <WidgetOverflowMenu
@@ -26,12 +26,20 @@ function resolveVar(value) {
26
26
  }
27
27
 
28
28
  /**
29
- * Resolve all string values in a feature object.
29
+ * Resolve all string values in a feature object, including nested items.
30
30
  */
31
31
  function resolveFeature(feature) {
32
32
  const resolved = {}
33
33
  for (const [key, val] of Object.entries(feature)) {
34
- resolved[key] = resolveVar(val)
34
+ if (key === 'items' && Array.isArray(val)) {
35
+ resolved[key] = val.map((item) => {
36
+ const r = {}
37
+ for (const [k, v] of Object.entries(item)) r[k] = resolveVar(v)
38
+ return r
39
+ })
40
+ } else {
41
+ resolved[key] = resolveVar(val)
42
+ }
35
43
  }
36
44
  return resolved
37
45
  }