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

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.2",
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.2",
7
+ "@dfosco/tiny-canvas": "3.11.0-beta.2",
8
8
  "@neodrag/react": "^2.3.1",
9
9
  "glob": "^11.0.0",
10
10
  "jsonc-parser": "^3.3.1"
@@ -440,6 +440,36 @@ 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 widgets = localWidgets ?? []
450
+ const widget = widgets.find((w) => w.id === targetId)
451
+ if (!widget) return
452
+
453
+ const el = scrollRef.current
454
+ if (!el) return
455
+
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
462
+
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
466
+
467
+ // Clean the URL param without triggering navigation
468
+ const url = new URL(window.location.href)
469
+ url.searchParams.delete('widget')
470
+ window.history.replaceState({}, '', url.toString())
471
+ }, [loading, localWidgets])
472
+
443
473
  // Persist viewport state (zoom + scroll) to localStorage on changes
444
474
  useEffect(() => {
445
475
  const el = scrollRef.current
@@ -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,102 @@ 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
+ /** Icon registry — maps icon name strings from config to React components. */
89
+ const ICON_REGISTRY = {
90
+ 'trash': DeleteIcon,
74
91
  'zoom-in': ZoomInIcon,
75
92
  'zoom-out': ZoomOutIcon,
76
93
  'edit': EditIcon,
77
94
  'open-external': OpenExternalIcon,
78
- 'toggle-private': EyeIcon,
95
+ 'eye': EyeIcon,
96
+ 'eye-closed': EyeClosedIcon,
79
97
  'copy': CopyIcon,
98
+ 'link': LinkIcon,
99
+ 'more': MoreIcon,
80
100
  }
81
101
 
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',
102
+ /** Danger-styled actions in the overflow menu. */
103
+ const DANGER_ACTIONS = new Set(['delete'])
104
+
105
+ /**
106
+ * Overflow menu — `...` button that opens a dropdown with menu-only actions.
107
+ */
108
+ function WidgetOverflowMenu({ widgetId, menuFeatures, onAction }) {
109
+ const [open, setOpen] = useState(false)
110
+ const menuRef = useRef(null)
111
+
112
+ useEffect(() => {
113
+ if (!open) return
114
+ function handlePointerDown(e) {
115
+ if (menuRef.current && !menuRef.current.contains(e.target)) {
116
+ setOpen(false)
117
+ }
118
+ }
119
+ document.addEventListener('pointerdown', handlePointerDown)
120
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
121
+ }, [open])
122
+
123
+ const handleItemClick = useCallback((action, e) => {
124
+ e.stopPropagation()
125
+ if (action === 'copy-link') {
126
+ const url = new URL(window.location.href)
127
+ url.searchParams.set('widget', widgetId)
128
+ navigator.clipboard.writeText(url.toString()).catch(() => {})
129
+ } else {
130
+ onAction?.(action)
131
+ }
132
+ setOpen(false)
133
+ }, [widgetId, onAction])
134
+
135
+ return (
136
+ <div ref={menuRef} className={styles.overflowWrapper}>
137
+ <Tooltip text="More actions" direction="n">
138
+ <button
139
+ className={styles.featureBtn}
140
+ onClick={(e) => { e.stopPropagation(); setOpen((v) => !v) }}
141
+ aria-label="More actions"
142
+ aria-expanded={open}
143
+ >
144
+ <MoreIcon />
145
+ </button>
146
+ </Tooltip>
147
+ {open && (
148
+ <div className={styles.overflowMenu}>
149
+ {menuFeatures.map((feature) => {
150
+ const Icon = ICON_REGISTRY[feature.icon]
151
+ const label = feature.label || feature.action
152
+ const isDanger = DANGER_ACTIONS.has(feature.action)
153
+ return (
154
+ <button
155
+ key={feature.id}
156
+ className={`${styles.overflowItem} ${isDanger ? styles.overflowItemDanger : ''}`}
157
+ onClick={(e) => handleItemClick(feature.action, e)}
158
+ >
159
+ {Icon && <Icon />}
160
+ <span>{label}</span>
161
+ </button>
162
+ )
163
+ })}
164
+ </div>
165
+ )}
166
+ </div>
167
+ )
90
168
  }
91
169
 
92
170
  /**
@@ -146,6 +224,7 @@ function ColorPickerFeature({ currentColor, options, onColorChange }) {
146
224
  * non-standard actions (anything other than 'delete').
147
225
  */
148
226
  export default function WidgetChrome({
227
+ widgetId,
149
228
  features = [],
150
229
  selected = false,
151
230
  widgetProps,
@@ -227,6 +306,9 @@ export default function WidgetChrome({
227
306
  <div className={`${styles.toolbarContent} ${showToolbar ? styles.toolbarContentVisible : ''}`}>
228
307
  <div className={styles.featureButtons}>
229
308
  {features.map((feature) => {
309
+ // Menu features are rendered in WidgetOverflowMenu
310
+ if (feature.menu) return null
311
+
230
312
  if (feature.type === 'color-picker') {
231
313
  return (
232
314
  <ColorPickerFeature
@@ -239,13 +321,13 @@ export default function WidgetChrome({
239
321
  }
240
322
 
241
323
  if (feature.type === 'action') {
242
- let Icon = ACTION_ICONS[feature.action]
243
- let label = ACTION_LABELS[feature.action] || feature.action
324
+ let Icon = ICON_REGISTRY[feature.icon]
325
+ let label = feature.label || feature.action
244
326
 
245
327
  // Toggle-private: swap icon/label based on current state
246
328
  if (feature.action === 'toggle-private') {
247
329
  if (widgetProps?.private) {
248
- Icon = EyeClosedIcon
330
+ Icon = ICON_REGISTRY['eye-closed']
249
331
  label = 'Private image — only visible locally'
250
332
  } else {
251
333
  label = 'Published image — deployed with canvas'
@@ -267,6 +349,11 @@ export default function WidgetChrome({
267
349
 
268
350
  return null
269
351
  })}
352
+ <WidgetOverflowMenu
353
+ widgetId={widgetId}
354
+ menuFeatures={features.filter((f) => f.menu)}
355
+ onAction={onAction}
356
+ />
270
357
  </div>
271
358
 
272
359
  <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,36 @@
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.
30
+ */
31
+ function resolveFeature(feature) {
32
+ const resolved = {}
33
+ for (const [key, val] of Object.entries(feature)) {
34
+ resolved[key] = resolveVar(val)
35
+ }
36
+ return resolved
37
+ }
38
+
12
39
  /**
13
40
  * Convert a config prop definition to the schema shape used by widgetProps.js.
14
41
  * Config uses `"default"`, schema uses `"defaultValue"`.
@@ -42,16 +69,30 @@ function buildSchemas() {
42
69
  return result
43
70
  }
44
71
 
72
+ /**
73
+ * Build resolved widget type entries with variables expanded in features.
74
+ */
75
+ function buildWidgetTypes() {
76
+ const result = {}
77
+ for (const [type, def] of Object.entries(widgetsConfig.widgets)) {
78
+ result[type] = {
79
+ ...def,
80
+ features: (def.features || []).map(resolveFeature),
81
+ }
82
+ }
83
+ return result
84
+ }
85
+
45
86
  /** All widget schemas, keyed by type string. */
46
87
  export const schemas = buildSchemas()
47
88
 
48
- /** Full widget config entries, keyed by type string. */
49
- export const widgetTypes = widgetsConfig.widgets
89
+ /** Full widget config entries (with resolved variables), keyed by type string. */
90
+ export const widgetTypes = buildWidgetTypes()
50
91
 
51
92
  /**
52
93
  * Get the feature list for a widget type.
53
94
  * @param {string} type — widget type string
54
- * @returns {Array} features array from config, or empty array
95
+ * @returns {Array} features array from config (variables resolved), or empty array
55
96
  */
56
97
  export function getFeatures(type) {
57
98
  return widgetTypes[type]?.features ?? []