@dfosco/storyboard-react 3.11.0-beta.1 → 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.1",
3
+ "version": "3.11.0-beta.3",
4
4
  "type": "module",
5
5
  "dependencies": {
6
- "@dfosco/storyboard-core": "3.11.0-beta.1",
7
- "@dfosco/tiny-canvas": "3.11.0-beta.1",
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"
@@ -440,6 +440,56 @@ export default function CanvasPage({ name }) {
440
440
  }
441
441
  }, [name, loading])
442
442
 
443
+ // Center on a specific widget if `?widget=<id>` is in the URL
444
+ useEffect(() => {
445
+ const params = new URLSearchParams(window.location.search)
446
+ const targetId = params.get('widget')
447
+ if (!targetId || loading) return
448
+
449
+ const el = scrollRef.current
450
+ if (!el) return
451
+
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
+ }
464
+
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
486
+
487
+ // Clean the URL param without triggering navigation
488
+ const url = new URL(window.location.href)
489
+ url.searchParams.delete('widget')
490
+ window.history.replaceState({}, '', url.toString())
491
+ }, [loading, localWidgets, localSources, jsxExports])
492
+
443
493
  // Persist viewport state (zoom + scroll) to localStorage on changes
444
494
  useEffect(() => {
445
495
  const el = scrollRef.current
@@ -1035,6 +1085,7 @@ export default function CanvasPage({ name }) {
1035
1085
  }}
1036
1086
  >
1037
1087
  <WidgetChrome
1088
+ widgetId={`jsx-${exportName}`}
1038
1089
  features={componentFeatures}
1039
1090
  selected={selectedWidgetId === `jsx-${exportName}`}
1040
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])
@@ -1,4 +1,4 @@
1
- import { useState, useCallback, useRef } from 'react'
1
+ import { useState, useCallback, useRef, useEffect } 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'
@@ -69,24 +69,179 @@ function CopyIcon() {
69
69
  )
70
70
  }
71
71
 
72
- const ACTION_ICONS = {
73
- 'delete': DeleteIcon,
72
+ function MoreIcon() {
73
+ return (
74
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
75
+ <path d="M8 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM1.5 9a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Zm13 0a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z" />
76
+ </svg>
77
+ )
78
+ }
79
+
80
+ function LinkIcon() {
81
+ return (
82
+ <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
83
+ <path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z" />
84
+ </svg>
85
+ )
86
+ }
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
+
105
+ /** Icon registry — maps icon name strings from config to React components. */
106
+ const ICON_REGISTRY = {
107
+ 'trash': DeleteIcon,
74
108
  'zoom-in': ZoomInIcon,
75
109
  'zoom-out': ZoomOutIcon,
76
110
  'edit': EditIcon,
77
111
  'open-external': OpenExternalIcon,
78
- 'toggle-private': EyeIcon,
112
+ 'eye': EyeIcon,
113
+ 'eye-closed': EyeClosedIcon,
79
114
  'copy': CopyIcon,
115
+ 'link': LinkIcon,
116
+ 'more': MoreIcon,
117
+ 'chevron-down': ChevronDownIcon,
118
+ 'download': DownloadIcon,
80
119
  }
81
120
 
82
- const ACTION_LABELS = {
83
- 'delete': 'Delete widget',
84
- 'zoom-in': 'Zoom in',
85
- 'zoom-out': 'Zoom out',
86
- 'edit': 'Edit',
87
- 'open-external': 'Open in new tab',
88
- 'toggle-private': 'Make private',
89
- 'copy': 'Copy widget',
121
+ /** Danger-styled actions in the overflow menu. */
122
+ const DANGER_ACTIONS = new Set(['delete'])
123
+
124
+ /**
125
+ * Overflow menu — `...` button that opens a dropdown with menu-only actions.
126
+ */
127
+ function WidgetOverflowMenu({ widgetId, menuFeatures, onAction }) {
128
+ const [open, setOpen] = useState(false)
129
+ const menuRef = useRef(null)
130
+
131
+ useEffect(() => {
132
+ if (!open) return
133
+ function handlePointerDown(e) {
134
+ if (menuRef.current && !menuRef.current.contains(e.target)) {
135
+ setOpen(false)
136
+ }
137
+ }
138
+ document.addEventListener('pointerdown', handlePointerDown)
139
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
140
+ }, [open])
141
+
142
+ const handleItemClick = useCallback((action, e) => {
143
+ e.stopPropagation()
144
+ if (action === 'copy-link') {
145
+ const url = new URL(window.location.href)
146
+ url.searchParams.set('widget', widgetId)
147
+ navigator.clipboard.writeText(url.toString()).catch(() => {})
148
+ } else {
149
+ onAction?.(action)
150
+ }
151
+ setOpen(false)
152
+ }, [widgetId, onAction])
153
+
154
+ return (
155
+ <div ref={menuRef} className={styles.overflowWrapper}>
156
+ <Tooltip text="More actions" direction="n">
157
+ <button
158
+ className={styles.featureBtn}
159
+ onClick={(e) => { e.stopPropagation(); setOpen((v) => !v) }}
160
+ aria-label="More actions"
161
+ aria-expanded={open}
162
+ >
163
+ <MoreIcon />
164
+ </button>
165
+ </Tooltip>
166
+ {open && (
167
+ <div className={styles.overflowMenu}>
168
+ {menuFeatures.map((feature) => {
169
+ const Icon = ICON_REGISTRY[feature.icon]
170
+ const label = feature.label || feature.action
171
+ const isDanger = DANGER_ACTIONS.has(feature.action)
172
+ return (
173
+ <button
174
+ key={feature.id}
175
+ className={`${styles.overflowItem} ${isDanger ? styles.overflowItemDanger : ''}`}
176
+ onClick={(e) => handleItemClick(feature.action, e)}
177
+ >
178
+ {Icon && <Icon />}
179
+ <span>{label}</span>
180
+ </button>
181
+ )
182
+ })}
183
+ </div>
184
+ )}
185
+ </div>
186
+ )
187
+ }
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
+ )
90
245
  }
91
246
 
92
247
  /**
@@ -146,6 +301,7 @@ function ColorPickerFeature({ currentColor, options, onColorChange }) {
146
301
  * non-standard actions (anything other than 'delete').
147
302
  */
148
303
  export default function WidgetChrome({
304
+ widgetId,
149
305
  features = [],
150
306
  selected = false,
151
307
  widgetProps,
@@ -227,6 +383,9 @@ export default function WidgetChrome({
227
383
  <div className={`${styles.toolbarContent} ${showToolbar ? styles.toolbarContentVisible : ''}`}>
228
384
  <div className={styles.featureButtons}>
229
385
  {features.map((feature) => {
386
+ // Menu features are rendered in WidgetOverflowMenu
387
+ if (feature.menu) return null
388
+
230
389
  if (feature.type === 'color-picker') {
231
390
  return (
232
391
  <ColorPickerFeature
@@ -239,13 +398,13 @@ export default function WidgetChrome({
239
398
  }
240
399
 
241
400
  if (feature.type === 'action') {
242
- let Icon = ACTION_ICONS[feature.action]
243
- let label = ACTION_LABELS[feature.action] || feature.action
401
+ let Icon = ICON_REGISTRY[feature.icon]
402
+ let label = feature.label || feature.action
244
403
 
245
404
  // Toggle-private: swap icon/label based on current state
246
405
  if (feature.action === 'toggle-private') {
247
406
  if (widgetProps?.private) {
248
- Icon = EyeClosedIcon
407
+ Icon = ICON_REGISTRY['eye-closed']
249
408
  label = 'Private image — only visible locally'
250
409
  } else {
251
410
  label = 'Published image — deployed with canvas'
@@ -265,8 +424,29 @@ export default function WidgetChrome({
265
424
  )
266
425
  }
267
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
+
268
443
  return null
269
444
  })}
445
+ <WidgetOverflowMenu
446
+ widgetId={widgetId}
447
+ menuFeatures={features.filter((f) => f.menu)}
448
+ onAction={onAction}
449
+ />
270
450
  </div>
271
451
 
272
452
  <Tooltip text="Select" direction="n">
@@ -217,3 +217,67 @@
217
217
  border-color: currentColor;
218
218
  box-shadow: 0 0 0 1px currentColor;
219
219
  }
220
+
221
+ /* Overflow menu */
222
+ .overflowWrapper {
223
+ position: relative;
224
+ display: flex;
225
+ align-items: center;
226
+ }
227
+
228
+ .overflowMenu {
229
+ position: absolute;
230
+ top: calc(100% + 4px);
231
+ right: 0;
232
+ min-width: 180px;
233
+ padding: 4px;
234
+ background: var(--bgColor-default, #ffffff);
235
+ border-radius: 10px;
236
+ box-shadow:
237
+ 0 0 0 1px rgba(0, 0, 0, 0.08),
238
+ 0 4px 12px rgba(0, 0, 0, 0.12);
239
+ z-index: 10;
240
+ }
241
+
242
+ :global([data-sb-canvas-theme^='dark']) .overflowMenu {
243
+ background: var(--bgColor-muted, #161b22);
244
+ box-shadow:
245
+ 0 0 0 1px rgba(255, 255, 255, 0.08),
246
+ 0 4px 12px rgba(0, 0, 0, 0.45);
247
+ }
248
+
249
+ .overflowItem {
250
+ all: unset;
251
+ cursor: pointer;
252
+ display: flex;
253
+ align-items: center;
254
+ gap: 8px;
255
+ width: 100%;
256
+ padding: 6px 10px;
257
+ font-size: 12px;
258
+ color: var(--fgColor-default, #1f2328);
259
+ border-radius: 6px;
260
+ box-sizing: border-box;
261
+ }
262
+
263
+ :global([data-sb-canvas-theme^='dark']) .overflowItem {
264
+ color: var(--fgColor-default, #e6edf3);
265
+ }
266
+
267
+ .overflowItem:hover {
268
+ background: var(--bgColor-neutral-muted, #eaeef2);
269
+ }
270
+
271
+ :global([data-sb-canvas-theme^='dark']) .overflowItem:hover {
272
+ background: var(--bgColor-neutral-muted, #272c33);
273
+ }
274
+
275
+ .overflowItemDanger:hover {
276
+ background: var(--bgColor-danger-muted, #ffebe9);
277
+ color: var(--fgColor-danger, #d1242f);
278
+ }
279
+
280
+ :global([data-sb-canvas-theme^='dark']) .overflowItemDanger:hover {
281
+ background: var(--bgColor-danger-muted, #490202);
282
+ color: var(--fgColor-danger, #f85149);
283
+ }
@@ -6,9 +6,44 @@
6
6
  *
7
7
  * The config is the single source of truth for widget definitions —
8
8
  * prop schemas, feature lists, labels, and icons all come from here.
9
+ *
10
+ * Supports `$variable` references in string values, resolved from
11
+ * the top-level `variables` object in widgets.config.json.
9
12
  */
10
13
  import widgetsConfig from '@dfosco/storyboard-core/widgets.config.json'
11
14
 
15
+ /** Variables defined in config — used to resolve `$key` references. */
16
+ const variables = widgetsConfig.variables || {}
17
+
18
+ /**
19
+ * Resolve `$variable` references in a string value.
20
+ * Returns the original value if it's not a string or doesn't start with `$`.
21
+ */
22
+ function resolveVar(value) {
23
+ if (typeof value !== 'string' || !value.startsWith('$')) return value
24
+ const key = value.slice(1)
25
+ return variables[key] ?? value
26
+ }
27
+
28
+ /**
29
+ * Resolve all string values in a feature object, including nested items.
30
+ */
31
+ function resolveFeature(feature) {
32
+ const resolved = {}
33
+ for (const [key, val] of Object.entries(feature)) {
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
+ }
43
+ }
44
+ return resolved
45
+ }
46
+
12
47
  /**
13
48
  * Convert a config prop definition to the schema shape used by widgetProps.js.
14
49
  * Config uses `"default"`, schema uses `"defaultValue"`.
@@ -42,16 +77,30 @@ function buildSchemas() {
42
77
  return result
43
78
  }
44
79
 
80
+ /**
81
+ * Build resolved widget type entries with variables expanded in features.
82
+ */
83
+ function buildWidgetTypes() {
84
+ const result = {}
85
+ for (const [type, def] of Object.entries(widgetsConfig.widgets)) {
86
+ result[type] = {
87
+ ...def,
88
+ features: (def.features || []).map(resolveFeature),
89
+ }
90
+ }
91
+ return result
92
+ }
93
+
45
94
  /** All widget schemas, keyed by type string. */
46
95
  export const schemas = buildSchemas()
47
96
 
48
- /** Full widget config entries, keyed by type string. */
49
- export const widgetTypes = widgetsConfig.widgets
97
+ /** Full widget config entries (with resolved variables), keyed by type string. */
98
+ export const widgetTypes = buildWidgetTypes()
50
99
 
51
100
  /**
52
101
  * Get the feature list for a widget type.
53
102
  * @param {string} type — widget type string
54
- * @returns {Array} features array from config, or empty array
103
+ * @returns {Array} features array from config (variables resolved), or empty array
55
104
  */
56
105
  export function getFeatures(type) {
57
106
  return widgetTypes[type]?.features ?? []